Skip to content

Lesson 6 — draft replies that are never sent

What you will build

The draft, and the proof that it stays a draft. A generated reply is stored as a row attached to the ticket: it has no send method, no route sends it, and the only code that writes to the outbox is the one lesson 7 unlocks. The transcript's last two lines are the assertion the whole path exists to make — the draft is waiting, and count is 0.

This is the lesson to read if you remember one thing: a model's output is data, and data does not have side effects. Everything that makes that true here is ordinary application code: a table, a state machine, and a mailer nobody can reach.

What you need

Nothing new.

examples/support_desk/mail.py
"""The mailer: a transactional outbox, so a sent reply and the state that says so commit together.

A real deployment hands the row to an SMTP relay or a provider's API from a small worker. What
matters here is the shape, and the shape is the point: the row is written inside the caller's
transaction, so either the reply is sent *and* the ticket says ``replied``, or neither happened.
The alternative — send first, record after — is a reply nobody can account for when the second
step fails.

A suggestion that was never approved leaves this table empty, which is what the path asserts.
"""

from __future__ import annotations

from typing import Annotated

from sqlalchemy import func, insert, select
from sqlalchemy.ext.asyncio import AsyncConnection

from support_desk.clock import utc_now
from support_desk.tables import outbox
from xyberos import Inject
from xyberos.database import DatabaseProvider


class Mailer:
    """Delivers a reply by recording it, so a send is a row and not a side effect nobody can see."""

    def __init__(self, database: Annotated[DatabaseProvider, Inject()]) -> None:
        self._database = database

    async def deliver(
        self,
        connection: AsyncConnection,
        *,
        tenant: str,
        ticket_id: int,
        recipient: str,
        body: str,
    ) -> int:
        """Write one approved body to the outbox inside the caller's transaction."""
        result = await connection.execute(
            insert(outbox).values(
                tenant=tenant,
                ticket_id=ticket_id,
                recipient=recipient,
                body=body,
                sent_at=utc_now(),
            )
        )
        inserted = result.inserted_primary_key
        if inserted is None:
            raise AssertionError("the insert reported no primary key")
        return int(inserted[0])

    async def messages(self, *, tenant: str, ticket_id: int) -> list[dict[str, object]]:
        """Return what was sent for one ticket, oldest first."""
        statement = (
            select(outbox)
            .where(outbox.c.tenant == tenant, outbox.c.ticket_id == ticket_id)
            .order_by(outbox.c.id)
        )
        async with self._database.transaction() as connection:
            rows = (await connection.execute(statement)).mappings().all()
        return [dict(row) for row in rows]

    async def count(self) -> int:
        """Return how many replies have been sent at all, which is the assertion that matters."""
        async with self._database.transaction() as connection:
            return int(
                (await connection.execute(select(func.count()).select_from(outbox))).scalar_one()
            )
examples/learn_hybrid/hybrid06_draft.py
"""Lesson 6 — draft replies that are never sent, because nothing in the application sends one.

Run it with:

    python -m learn_hybrid.hybrid06_draft
"""

from __future__ import annotations

import tempfile
from pathlib import Path

from support_desk.app import create_app
from support_desk.migrate import migrate
from support_desk.security import PrincipalVerifier

from xyberos.database import SqliteSettings
from xyberos.http import HttpAdapter
from xyberos.http.testing import create_test_client

SECRET = "a-secret-the-deployment-keeps"
VERIFIER = PrincipalVerifier(SECRET)
ADA = VERIFIER.headers("ada@example.com", "acme", ["requester"])
SAM = VERIFIER.headers("sam@desk", "acme", ["support_agent"])
LEE = VERIFIER.headers("lee@desk", "acme", ["support_lead"])


def main() -> None:
    """Classify a ticket, draft a reply with a quoted policy, and try to send it."""
    with tempfile.TemporaryDirectory() as directory:
        settings = SqliteSettings(path=Path(directory) / "support.db")
        migrate(settings)

        app = create_app(settings=settings, secret=SECRET)
        with create_test_client(HttpAdapter(app)) as client:
            client.post("/knowledge", headers=LEE)
            client.post(
                "/tickets",
                json={"subject": "Where is my refund?", "body": "Order 42 came back on Monday."},
                headers=ADA,
            )

            classified = client.post("/tickets/1/classify", headers=SAM).json()
            print("classified as:", classified["category"], "/", classified["confidence"])

            draft = client.post("/tickets/1/draft", headers=SAM).json()
            print("the draft:", draft["body"])
            print("  written by:", draft["model"], "- approved by:", draft["approved_by"])
            print("  the ticket is:", client.get("/tickets/1", headers=SAM).json()["state"])

            sent = client.get("/tickets/1/sent", headers=SAM).json()
            print("messages sent so far:", sent["count"])

            refused = client.post("/tickets/1/send", headers=SAM)
            print("sending the draft ->", refused.status_code, refused.json()["error"]["message"])

            trail = client.get("/tickets/1/audit", headers=SAM).json()["events"]
            print("the trail records the draft, and no send:", [event["action"] for event in trail])
            print("still nothing sent:", client.get("/tickets/1/sent", headers=SAM).json()["count"])


if __name__ == "__main__":
    main()

How to run it

$env:PYTHONPATH = "src;examples"
python -m learn_hybrid.hybrid06_draft
PYTHONPATH=src:examples python -m learn_hybrid.hybrid06_draft

What it prints

classified as: refund / high
the draft: A refund takes three business days to appear.
  written by: fake-model - approved by: None
  the ticket is: awaiting_approval
messages sent so far: 0
sending the draft -> 409 send is not allowed while a ticket is in 'awaiting_approval'
the trail records the draft, and no send: ['opened', 'classified', 'drafted']
still nothing sent: 0

send is refused with a 409 that names the state, not a 500 and not a silent success: the request was well formed, the caller was allowed, and the ticket is not in a state where sending is a thing that can happen. That is what a transition table buys — a refusal a client can act on.

The trail line is the second half of the boundary: the draft is recorded, so an incident review can see that a model wrote something and that nobody sent it.

Common mistakes

  • Sending "as a convenience" after drafting. The moment a draft can send itself, the approval transition is decoration. Keep the effect in a route a person calls.
  • Storing the draft as an email and hoping. A draft in a mail queue is a draft that will leave the building when the queue is flushed.
  • Letting the model write the state. attach_suggestion writes awaiting_approval; the model cannot name a state, and the payload cannot either.
  • No record of what the model wrote once it is edited. Keep the draft and the approved text in separate columns, so "what did it say" and "what did we say" stay different questions.
  • A send that succeeds and a commit that fails. Write the outbox row in the same transaction as the state change, which is what mail.py does.

Next

Lesson 7 is the approval itself: who may give it, what it records, and what it unlocks.