# Build a Ticket Workflow: Signed Delivery, Evidence and Human Approval

> Connect a real HTTP receiver to retrieval, proposal validation and a stored approval, then verify twelve local failure and recovery scenarios.

- Source: https://www.zarifautomates.com/blog/ticket-agent-workflow-capstone
- Published: 2026-09-17
- Updated: 2026-09-17
- Pillar: Agents & AI Engineering
- Tags: agent-course, ai-engineering, practical-guide
- Author: Zarif

---

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](/blog/how-to-use-webhooks-ai-automation), [RAG](/blog/how-to-build-ai-agent-with-rag) and [approval](/blog/how-to-build-ai-agent-human-in-loop-approval) lessons first if their boundaries are unfamiliar.

Download and extract the [ticket workflow capstone](/downloads/agent-course/ticket-workflow-capstone-v1.zip). 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:

```bash
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:

```text
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:

```bash
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](https://docs.python.org/3/library/hmac.html#hmac.compare_digest) 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:

```json
{
  "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:

```json
{
  "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.

```bash
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](https://docs.python.org/3/library/sqlite3.html#transaction-control) 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](/blog/agent-engineer-career-guide) explains how to turn that work into an assessable portfolio. The [course index](/blog/pillar/agents-and-ai-engineering#agent-course-heading) lets you revisit the component that needs more practice.

## Related Guides

- [Use Cursor to Build a Ticket Approval Interface You Can Test](/blog/how-to-use-cursor-ai-to-build-web-applications)
- [Plan and Execute: Build a Bounded Ticket Workflow](/blog/how-to-build-ai-agent-that-plans-and-executes-tasks)
- [Build an MCP Server and Client for a Ticket-Label Tool](/blog/how-to-build-an-ai-agent-using-mcp-model-context-protocol)
- [Your Research Agent Needs an Evidence Ledger Before It Needs a Better Prompt](/blog/market-research-agent-workflow-teardown)

## Continue the course

Lesson 17 of 17.

Previous lesson: [Build an n8n Ticket-Triage Workflow and Test Its Failure Paths](https://www.zarifautomates.com/blog/how-to-use-n8n-for-advanced-ai-workflow-automation.md).

[Browse available lessons](https://www.zarifautomates.com/blog/pillar/agents-and-ai-engineering#agent-course-heading).
