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.
"""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()
)
"""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¶
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_suggestionwritesawaiting_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.pydoes.
Next¶
Lesson 7 is the approval itself: who may give it, what it records, and what it unlocks.