Build a Ticket Workflow: Signed Delivery, Evidence and Human Approval
A refund question arrives twice. Your workflow finds the billing policy and proposes a label. Someone approves it, the database changes, and the caller loses track of the result. The next request must recover the recorded outcome without applying the change again.
This capstone connects the course's delivery, retrieval and approval work into one runnable local workflow. It uses real HTTP and SQLite. Model responses are authored fixtures, the signing key is deliberately public, and the reviewer is a fixed synthetic identity. You can inspect every handoff without buying API access or connecting a customer system.
The result is a tested application boundary around a simulated model response. It is not a deployed autonomous agent or evidence of live model quality.
Set up the complete artifact
You need Python 3.10 or newer and a terminal. The example uses only the standard library. Complete the webhook, RAG and approval lessons first if their boundaries are unfamiliar.
Download and extract the ticket workflow capstone. Its files have separate jobs:
| File | Role |
|---|---|
capstone.py | HTTP delivery, inbox, preparation and command-line workflow |
policies.json | Four routing documents, including foreign-owner and inactive distractors |
model-output-fixtures.json | An authored billing proposal with a citation; no model call |
approval_lesson.py | Exact approval core from the earlier lesson |
rag_lesson.py | Exact retrieval module from the RAG lesson |
verify_capstone.py | Twelve integrated scenarios and source hashes |
observed-results.json | Recorded run for comparison |
Run the full suite:
python3 verify_capstone.py > my-results.json
Use python if that is your installed command. The observed run used Python 3.12.14 and reported scenarios: 12, passed: 12, and model_requests: 0. Each scenario uses a temporary database; ingress is an actual loopback HTTP request. A failed assertion exits nonzero rather than producing a passing report.
Compare my-results.json with observed-results.json: the scenario names, outcomes and source_sha256 values should match. Your python version field may differ. A source-hash difference means you are testing different bytes; investigate that before interpreting an outcome difference as a workflow regression.
Follow one event through the system
The application follows this sequence:
Signed HTTP body
-> validated, durable inbox row
-> owner-scoped policy retrieval
-> authored proposal + citation validation
-> stored exact proposal and pending approval
-> explicit reviewer decision
-> version-checked ticket update + receipt + audit event
These are connected stages operating on the same event and database. The verifier does not merely run the earlier lesson scripts beside each other. It reuses their approval and retrieval functions where those contracts fit.
The event contains an ID, ticket.created, ticket T100, and the question “Where should this refund request go?” It cannot supply an owner, reviewer, database path or tool name. The application chooses owner demo-a and limits the exercise to that one ticket. That restriction is a fixture scope, not user authentication.
Prepare the proposal without applying it
Start a fresh example:
python3 capstone.py prepare --db demo.sqlite
python3 capstone.py inspect --db demo.sqlite
Prepare refuses to replace an existing database. Use a different filename for a new attempt. It starts a short-lived receiver on 127.0.0.1, sends the signed event, stops the receiver and processes the accepted work. It leaves a pending approval; the ticket is still unassigned at version 1 and there are zero receipts.
The delivery response is HTTP 202 with state received. The local protocol signs timestamp, a period and the exact body bytes using HMAC-SHA256. Timestamp freshness and a constant-time signature comparison happen before JSON parsing. See Python's HMAC documentation for the comparison primitive. This is the course's own protocol, not a GitHub or Stripe adapter.
The inbox stores the raw body and its hash before acknowledging acceptance. The same event ID and body return HTTP 200 with duplicate: true. Reusing the ID for different signed content returns 409. It does not replace the original question.
Because the example key is public, anyone who knows this artifact can construct a valid signature. It demonstrates verification mechanics, not protection of a deployed endpoint. The receiver binds only to loopback and runs only during delivery.
Inspect the evidence and proposed action
Retrieval filters documents to active entries belonging to demo-a or public, then ranks lexical term overlap. For the refund question, the eligible hit is:
{
"id": "billing-routing",
"version": 1,
"text": "Send refund requests to billing review."
}
The fixture proposes label billing and cites that document's exact version and text. Validation requires a closed output shape, an allowed label, a citation among the retrieved hits, an exact quotation and agreement with the policy document's routing label. An inactive or foreign-owner document cannot authorize the result merely because its ID appears in the response.
This proves a narrow structural relationship between supplied evidence and the proposed label. It does not prove that lexical retrieval found every relevant policy or that a language model understood the question. A missing output fixture becomes an abstention; an unsupported citation becomes a rejected work item. Neither creates an approval.
Before requesting approval, the worker stores the complete proposal, including the observed ticket version, creation time and expiry. Inspect prepared and trace in the inbox output to see those records. The proposal should be:
{
"owner": "demo-a",
"ticket_id": "T100",
"label": "billing",
"expected_version": 1
}
Approve, apply and retry in separate processes
Read the proposal, then run these commands within its 120-second lifetime. This exercise sets expires_at to now + 120 in capstone.py; it is a local example policy, not a universal approval timeout.
python3 capstone.py decide --db demo.sqlite --choice approved
python3 capstone.py apply --db demo.sqlite
python3 capstone.py apply --db demo.sqlite
python3 capstone.py inspect --db demo.sqlite
The first apply returns completed, replayed: false, billing and version 2. The next returns the stored outcome with replayed: true. Inspection still shows one receipt and version 2. Each command starts a new Python process, so the result comes from SQLite rather than an in-memory flag.
The completion receipt remains readable after expiry because that request retrieves a past result; it does not authorize another update. An approved proposal whose first apply arrives at expiry is refused. If you waited too long during the walkthrough, use a new database and repeat Prepare.
For rejection, prepare a different database and choose --choice rejected. A subsequent apply refuses with approval_not_active:rejected; the ticket stays unassigned. The command's nonzero exit and error are expected evidence of that refusal.
The inbox state review_created means processing handed off to an approval record. It is not the final action status. Read the approval state, receipt and audit history to distinguish pending, rejected, expired, stale and completed outcomes.
Test both recovery boundaries
Preparing the work and creating its approval are separate commits. The suite injects an exception after the approval commit but before marking the inbox handoff. A new worker process reads the same prepared bytes and expiry, checks the already-created approval and finishes the handoff. It does not silently rebuild the proposal using a newer target version or create another approval.
Applying the approved action updates the ticket, receipt and audit state in one SQLite transaction. The suite injects an exception after the ticket update. A new process sees the old label, version 1 and zero receipts. A later apply succeeds once. Python's transaction-control documentation explains the commit/rollback primitives used here.
These are controlled exceptions and fresh-process checks. They are not process-kill, power-loss or distributed exactly-once tests. Run one worker at a time. A remote CRM would require its own idempotency and reconciliation design; a local transaction cannot roll back a remote side effect.
Read every result, including refusals
| Scenario | Required observed outcome |
|---|---|
| Completed action, new process, retry after expiry | Same stored result; one receipt |
| Ticket version changes after approval | Stale refusal; original label remains |
| First apply after expiry | Expired refusal; zero receipts |
| Reviewer rejects | Apply refuses; zero receipts |
| Apply before approval | Apply refuses; zero receipts |
| No retrieved evidence and abstaining output | No approval created |
| Citation names another owner's document | Output rejected; no approval created |
| Event targets a different ticket | HTTP 400 before inbox creation |
| Signature covers different bytes | HTTP 401 before inbox creation |
| Same ID, different signed content | HTTP 409; original event still completes normally |
| Stop after approval creation | New worker resumes; one approval and one final receipt |
| Failure inside action transaction | Rollback visible in a new process; retry completes once |
The suite also repeats each valid ingress delivery before processing and checks that the worker becomes idle after handing off or refusing the item. The report includes SHA-256 fingerprints for code and fixtures. Those hashes identify the tested inputs; they do not certify the application as secure.
Make one change and account for its effect
In a disposable copy, change the fixture citation ID from billing-routing to other-owner, keeping the other fields. Run Prepare against a new database. The delivery should still be accepted, but processing should return rejected with unsupported_label_or_citation. Inspection should show zero approval records and zero receipts. Restore the fixture afterward.
That exercise separates valid delivery from valid proposed work. A signed sender can still deliver a request whose resulting proposal should be refused.
Keep your final packet small enough to review: code and fixture hashes, the twelve-case report, one manually inspected approval, one changed-fixture result and a list of untested boundaries. If you later substitute a real model, retain this output contract and add task-quality evaluation, cost/timeout measurements and adversarial inputs. If you substitute an external service, test its action and recovery behavior separately.
The course ends with a reproducible workflow and evidence of its limits. The Agent Engineer career guide explains how to turn that work into an assessable portfolio. The course index lets you revisit the component that needs more practice.
