« Phase 10 · Warmup · Track Overview
Lab 01 — The Action Gateway
The problem
An agent has decided to release a held payment for 250,000 AED. Identity says who is asking (Phase 08); policy says they may (Phase 09). This is the layer where the decision becomes money leaving the bank.
Between the decision and the effect, seven things have to be true, and every one of them has a production incident behind it:
- The arguments are well-formed and the currency matches the debit account.
- A network timeout on the way back does not turn one payment into two.
- Two distinct humans approved — neither of them the agent, neither of them the requester.
- Core banking being sick does not turn into a retry storm that keeps it sick.
- If step 3 of a five-step process fails, steps 2 and 1 are undone — in that order.
- No account number or credential appears in any log line.
- Six months later, an examiner can be shown who authorized this and prove the record was not edited.
You build all seven.
What you build
| # | Component | What it does |
|---|---|---|
| 1 | SideEffect, EFFECT_POLICY | the declared class that derives retry, key, approval and audit policy |
| 2 | validate_schema | a JSON-Schema subset returning every error, sorted |
| 3 | ToolContract | schema plus the business invariants a schema cannot express |
| 4 | IdempotencyStore | all three replay cases, plus the in-flight case people forget |
| 5 | CircuitBreaker | rolling window, minimum throughput, half-open probes, defined open behaviour |
| 6 | redact | account numbers and secrets removed before serialization |
| 7 | AuditLog, AuditRecord | hash-chained, with a verifier that catches all three edit shapes |
| 8 | check_dual_control | two distinct humans, inclusive threshold, requester excluded |
| 9 | ActionGateway | the composition, in an order that is itself the design |
| 10 | Saga, SagaStep | forward steps, reverse compensations, and the orphan case |
Key concepts
| Concept | Where | Why it matters |
|---|---|---|
| No default side-effect class | EFFECT_POLICY | a default here is a default retry policy, and both defaults are wrong |
| Retry is derived, not chosen | execute | every double-payment story is a call site that chose |
| Irreversible ≠ non-idempotent | EFFECT_POLICY | "did it happen?" is unanswerable for a released payment; do not guess |
| Schema then invariants | ToolContract.check | running invariants on a bad payload raises KeyError and hides the real error |
| Business rules are contract | invariants | rules the gateway does not check live in the prompt, where they can be argued with |
| bool is not int | validate_schema | isinstance(True, int) is True; the most common validator bug in Python |
| All errors, sorted | validate_schema | one error per round trip is four round trips |
| Same key + same hash | IdempotencyStore | return the stored response; execute zero more times |
| Same key + different hash | IdempotencyStore | conflict, and execute never — the caller has a bug |
| In-flight is a fourth case | begin | a queue turns a double-click into a double payment one second later |
| Hash excludes the trace id | request_hash | otherwise every retry is a 409 |
| Minimum throughput | CircuitBreaker | 1 failure in 1 call is a 100% rate; without it, every 3 a.m. blip opens |
| Half-open is one probe | record | full traffic at a recovering downstream re-kills it |
| Closing clears the window | _transition | otherwise the failures that opened it immediately re-open it |
| Open must do something | fallback | a breaker that only fails faster converted a slow error into a quick one |
| Breaker after the key check | execute | an open circuit must not consume an idempotency key |
| Dual control before the key | execute | a rejected approval must not burn the key the caller will reuse |
| Distinct approvers | check_dual_control | one person clicking twice is one person |
| The threshold is inclusive | check_dual_control | the boundary transaction is the one the auditor picks |
| Redact before serializing | redact | "we'll scrub the logs later" — it already left the process |
| Refusals are audited too | _refuse | "the platform stopped it" is the sentence that proves the control worked |
| Compensation ≠ rollback | Saga | the intermediate state was visible; the reversal is a new, visible action |
| Reverse order | _compensate | step 3 depends on step 2's effect |
| Orphans are loud | SagaOutcome.orphaned | there is no third level of undo |
| Chained, not just hashed | AuditRecord.digest | including prev_hash is what makes editing history detectable |
Files
| File | Role |
|---|---|
| lab.py | your implementation |
| solution.py | reference; python solution.py runs an eight-part worked session |
| test_lab.py | 120 tests |
| requirements.txt | pytest |
Run
pip install -r requirements.txt
pytest test_lab.py -v
LAB_MODULE=solution pytest test_lab.py -v
python solution.py
Success criteria
-
All 120 tests green against your
lab.py. -
validate_schemarejectsTruefor aninteger, and returns every error sorted. - Invariants never run on a structurally invalid payload.
- The same key with the same request returns the stored response and executes once.
- The same key with a different request is a conflict and executes never.
- A conflict is detected before the in-flight check.
- The request hash ignores the trace id and the model version, and is key-order insensitive.
- A write with no idempotency key is refused without touching the downstream.
- An irreversible action is attempted exactly once; a read is retried.
- The breaker opens exactly at the threshold, and not below the minimum throughput.
- Half-open admits one probe; one failed probe re-opens; closing clears the window.
- An open breaker does not consume an idempotency key.
- An agent cannot be its own second approver, nor can the requesting user.
- The dual-control threshold is inclusive.
- A saga failing at step 3 compensates 2 then 1, and never compensates 3.
- A failing compensation is reported as an orphan and does not stop the others.
- Editing a record's content, re-hashing it, or deleting it all fail verification.
- No secret or full account number appears in any audit record.
How this maps to the real stack
| This lab | The real thing | What we simplified |
|---|---|---|
ActionGateway | an API gateway (Azure APIM, Kong, Envoy) plus a mediation service | no HTTP, no auth middleware, no rate limiting |
ToolContract | OpenAPI + a rules engine, or the MCP tool schema from Phase 02 | a JSON-Schema subset; no $ref, no oneOf, no format registry |
IdempotencyStore | Redis or Postgres with a unique index, TTL, and a real transaction | in-memory; no concurrency, so the in-flight case is asserted rather than raced |
CircuitBreaker | Polly, resilience4j, Envoy outlier detection, Istio | no bulkheads, no adaptive concurrency, no per-endpoint isolation |
Saga | Temporal, Azure Durable Functions, Camunda, or an outbox + state machine | no durability — a process restart loses the saga, which is the whole point of Temporal |
AuditLog | an append-only store (Azure immutable blob, QLDB, Kafka + WORM) | no persistence, no external anchoring, no retention policy |
redact | Presidio, a DLP service, or a structured-logging processor | regex only; no NER, no per-jurisdiction rules |
check_dual_control | a maker-checker workflow with its own UI and authentication | approvals arrive as strings; nothing authenticates the approver |
Honest limits. The saga is not durable — a process restart loses it, and durability is the
entire reason Temporal and Durable Functions exist. The idempotency store is in-memory and
single-threaded, so the in-flight case is demonstrated rather than raced; a real one needs a
conditional write and must handle the crash-after-write-before-response window. Redaction is regex,
so it catches account-shaped digits and misses everything else — and it over-matches too: a
ten-digit phone number is redacted as an account, which is a false positive you would tune out with
a real DLP engine. Nothing authenticates an approver; approvals=("ahmed",) is a string, and in
production that must be a signed assertion from the moment of approval. And the audit chain is
tamper-evident only: whoever can write the log can rebuild it, and closing that requires an
external anchor this file cannot provide.
Extensions
- Make the saga durable. Persist step state, then kill the process mid-saga and resume. You will discover that "did step 2 complete?" needs the idempotency store — which is the insight.
- Race the idempotency store. Two threads, same key. Then fix it with a conditional write
(
INSERT ... ON CONFLICT DO NOTHING) and confirm exactly one wins. - The crash window. Downstream applied it; the gateway died before storing the response. What does the retry see, and how does the caller learn the truth?
- Signed approvals. Replace the approver strings with signed assertions carrying a timestamp and an audience, verified at the moment of use (Phase 08).
- External anchoring. Publish the head hash hourly to a store you cannot write to. Now tamper-evident becomes tamper-resistant, and you can say so precisely.
- Bulkheads. Add a per-downstream concurrency limit so one slow dependency cannot consume every worker. The breaker stops a failing dependency; the bulkhead stops a slow one, and slow is the more common outage.
- Adaptive concurrency. Replace the fixed breaker thresholds with a gradient-based limiter
(Netflix's
concurrency-limits) and compare behaviour under a partial brownout. - The outbox. Emit an event per audited action transactionally with the action itself (Phase 12), so the audit stream cannot diverge from what happened.
Interview / resume bullets
- "Built the bank's action gateway — the enforcement boundary between agent decisions and the core estate — where every tool declares a side-effect class from which the platform derives its retry, idempotency, approval and audit policy, so no integration author ever chooses whether a payment is safe to retry."
- "Implemented idempotency with all three replay cases plus in-flight detection, so a network timeout on a payment release returns the original reference instead of releasing a second payment."
- "Ran multi-step work as sagas with idempotent compensations executed in reverse, and made a failed compensation a loud, paged orphan rather than a swallowed exception — because there is no third level of undo."
- "Made the audit log hash-chained and verifiable, so 'tamper-evident' is a property the code demonstrates rather than a claim in a control document."
- "Gave the circuit breaker a defined open behaviour — a labelled stale answer — rather than a faster failure, which kept read paths available during a core-banking brownout."