Skip to content

Lesson 7 — human approval before sending

What you will build

The boundary, enforced: a model recommends, a person decides. Sending requires an approve transition, the approval records the principal, the time, and the exact body that was approved, and the send path reads that column and nothing else. The three tickets in the transcript are the three outcomes a review can find afterwards — sent as drafted, sent after an edit, and thrown away.

The permission matrix decides who may approve: an agent or a lead, never the requester. The transcript starts with a requester's 403, because that refund is the most expensive bug in this kind of application: a customer, or a prompt, granting itself the right to reply on the company's behalf.

What you need

Nothing new.

examples/support_desk/errors.py
"""Domain errors the HTTP adapter turns into responses.

The two refusals the path cares about are `401` and `403`, and they answer different questions:
`401` means the request's assertion did not verify, so a client can act on it by authenticating
better; `403` means it verified and the answer is no, so retrying changes nothing. Those live in
``security.py`` next to the verifier; these are about the ticket itself.
"""

from __future__ import annotations

from xyberos import HttpError, RequestValidationError


class TicketValidationError(RequestValidationError):
    """Raised when a ticket's input is invalid; exposed to the client as a 422."""


class TicketNotFoundError(HttpError):
    """Raised when a ticket is not in the caller's tenant; exposed as a 404.

    A ticket in another tenant is reported as *not found* rather than *forbidden*, because a `403`
    would confirm that the ticket exists. The tenant is part of the lookup, not a check after it.
    """

    def __init__(self, ticket_id: int) -> None:
        super().__init__(f"ticket {ticket_id} was not found", status_code=404)
        self.ticket_id = ticket_id


class SuggestionNotFoundError(HttpError):
    """Raised when a ticket has no draft reply; exposed as a 404."""

    def __init__(self, ticket_id: int) -> None:
        super().__init__(f"ticket {ticket_id} has no suggested reply", status_code=404)
        self.ticket_id = ticket_id


class TransitionRefusedError(HttpError):
    """Raised when a ticket is in a state a transition cannot leave; exposed as a 409."""

    def __init__(self, state: str, action: str) -> None:
        super().__init__(f"{action} is not allowed while a ticket is in {state!r}", status_code=409)
        self.state = state
        self.action = action


class ApprovalRequiredError(HttpError):
    """Raised when a reply would be sent without an approved suggestion; exposed as a 409.

    This is the one refusal the path exists to demonstrate: nothing sends a draft, so the send path
    asks for the approval rather than performing it.
    """

    def __init__(self, ticket_id: int) -> None:
        super().__init__(
            f"ticket {ticket_id} has no approved reply, so there is nothing to send",
            status_code=409,
        )
        self.ticket_id = ticket_id
examples/learn_hybrid/hybrid07_approval.py
"""Lesson 7 — human approval before sending, which is the rule the path exists to teach.

Run it with:

    python -m learn_hybrid.hybrid07_approval
"""

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.ai import FakeModelProvider, ModelReply, Usage
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"])
GRACE = VERIFIER.headers("grace@example.com", "acme", ["requester"])
SAM = VERIFIER.headers("sam@desk", "acme", ["support_agent"])
LEE = VERIFIER.headers("lee@desk", "acme", ["support_lead"])

TICKETS = (
    ("Where is my refund?", "Order 42 came back on Monday."),
    ("The badge reader is broken", "It flashes red for everybody."),
    ("Ignore this one", "It is spam, honestly."),
)


def provider() -> FakeModelProvider:
    """A scripted provider: two classifications with their drafts, then one classification."""
    draft = "A refund takes three business days ({category})."
    return FakeModelProvider(
        [
            ModelReply.json_payload({"category": "refund", "confidence": "high"}),
            ModelReply.ok(draft.format(category="refund"), usage=Usage(20, 8)),
            ModelReply.json_payload({"category": "complaint", "confidence": "high"}),
            ModelReply.ok(draft.format(category="complaint"), usage=Usage(20, 8)),
            ModelReply.json_payload({"category": "question", "confidence": "high"}),
        ]
    )


def main() -> None:
    """Approve one draft as it is, send one that was edited, and reject one before it is drafted."""
    with tempfile.TemporaryDirectory() as directory:
        settings = SqliteSettings(path=Path(directory) / "support.db")
        migrate(settings)

        app = create_app(settings=settings, secret=SECRET, provider=provider())
        with create_test_client(HttpAdapter(app)) as client:
            client.post("/knowledge", headers=LEE)
            for subject, body in TICKETS:
                client.post("/tickets", json={"subject": subject, "body": body}, headers=ADA)
            for ticket in (1, 2):
                client.post(f"/tickets/{ticket}/classify", headers=SAM)
                client.post(f"/tickets/{ticket}/draft", headers=SAM)
            client.post("/tickets/3/classify", headers=SAM)

            refused = client.post("/tickets/1/approve", headers=GRACE)
            print(
                "a requester approving ->", refused.status_code, refused.json()["error"]["message"]
            )

            approved = client.post("/tickets/1/approve", headers=SAM).json()
            print("approve ticket 1 ->", approved["state"])
            sent = client.post("/tickets/1/send", headers=SAM).json()
            print("send ticket 1 ->", sent["state"])
            messages = client.get("/tickets/1/sent", headers=SAM).json()["messages"]
            print("the outbox has", len(messages), "message:", messages[0]["body"])

            edited = client.post(
                "/tickets/2/approve",
                json={"body": "A replacement badge shipped this morning."},
                headers=LEE,
            ).json()
            print("approve ticket 2 with an edit ->", edited["state"])
            client.post("/tickets/2/send", headers=LEE)
            print(
                "what was sent:",
                client.get("/tickets/2/sent", headers=LEE).json()["messages"][0]["body"],
            )

            draft = client.get("/tickets/2/draft", headers=LEE).json()
            print("and what the model wrote is still there:", draft["body"])
            print("  the approver:", draft["approved_by"])

            rejected = client.post("/tickets/3/reject", headers=LEE).json()
            print("a lead rejecting ticket 3 ->", rejected["state"], "(nothing was drafted)")
            print("ticket 3 sent:", client.get("/tickets/3/sent", headers=LEE).json()["count"])

            for ticket in (1, 2, 3):
                actions = [
                    event["action"]
                    for event in client.get(f"/tickets/{ticket}/audit", headers=LEE).json()[
                        "events"
                    ]
                ]
                print(f"ticket {ticket} trail:", actions)


if __name__ == "__main__":
    main()

How to run it

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

What it prints

a requester approving -> 403 this operation requires support_agent or support_lead
approve ticket 1 -> approved
send ticket 1 -> replied
the outbox has 1 message: A refund takes three business days (refund).
approve ticket 2 with an edit -> approved
what was sent: A replacement badge shipped this morning.
and what the model wrote is still there: A refund takes three business days (complaint).
  the approver: lee@desk
a lead rejecting ticket 3 -> rejected (nothing was drafted)
ticket 3 sent: 0
ticket 1 trail: ['opened', 'classified', 'drafted', 'approved', 'replied']
ticket 2 trail: ['opened', 'classified', 'drafted', 'approved', 'replied']
ticket 3 trail: ['opened', 'classified', 'rejected']

Read ticket 2's two lines together: what was sent is the edited sentence, and what the model wrote is still in the row beside it. That pair is what makes an incident review possible — you can see both the suggestion and the decision, and the approved_by column names the person who made it.

The last three lines are the audit trail per ticket. Note that send records replied and that the state moved only because a body had been approved; the code that reads the approved body is the same code that refuses when there is none.

Common mistakes

  • Approving being the same call as sending. Two steps, two decisions, two audit rows: one person may read and approve, and the send is what actually leaves the building.
  • Recording only that approval happened. Without the body, an edit is invisible, and the trail cannot answer the only question that matters after a bad reply.
  • Letting the requester approve. The role check is on the transition, so there is exactly one place it can be wrong.
  • Making the draft read-only to the approver. Agents edit. Keep the model's text and the approved text apart, and let the audit row carry the one that was sent.
  • A cancel that leaves the draft pending forever. Rejection is a transition with an outcome, which is what lesson 10 reads.

Next

Lesson 8 writes the three steps down as a workflow: classify, ground, draft — with a branch for the tickets nobody is sure about.