ADR-038: Concurrency-Safe, Recoverable Applicant Finalization
On this page
Status
Accepted (2026-07-13)
Realized by epic &71 (#1046 plan/ADR, #1047 persons receipt + generation gate + held events, #1048 persons
control surface + shred compensation, #1049 persons-client + shared consts, #1050 applications saga store,
#1051 finalize_draft rewrite, #1052 reaper guard, #1053 reconciler + pruner, #1054 cross-service
acceptance + flag flip, #1055 existing-orphan sweep); see
the plan. Resolves #1005.
Amends
-
ADR-026 §5 (materialize-at-finalize) and §6 (sliding reaper). ADR-026 §5 guarantees the intra-
canopy-applicationsapplications-INSERT
application_drafts-DELETE are one transaction, and §6 serialises the reaper with finalize on the draft row. ADR-026 §5 explicitly scoped the cross-service persons writes out ("that cross-service ordering is the pre-existing orchestration concern, not introduced by this ADR"). This ADR closes that gap: the persons writes become idempotent and recoverable, the single final transaction additionally records saga completion, and the reaper additionally refuses to reap a draft with a live finalize operation. ADRs are immutable once accepted, so this ADR amends ADR-026 rather than editing it.
Builds on / relates to
-
ADR-025 — cross-service referential integrity. The orphaned-persons-graph failure this ADR fixes is the ADR-025 failure mode (cross-service IDs persisted with no owning row), specialised to finalize and made acute by concurrency and crashes. ADR-025 deferred a one-shot
cargo xtask seed sweep-orphanscleanup; epic &71’s MR9 (sweep-finalize-orphans) is a narrower, finalize-specific realisation of that deferred follow-up, keyed on saga state rather than graph inference. -
ADR-036 — crypto-shred redaction. Compensation reuses ADR-036’s DEK-tombstone primitive as the only sanctioned way to remove PII: the
redaction_keysone-way trigger rejects DELETE/TRUNCATE, so a partial graph is undone by shredding the wrapping key, not by deleting rows. This ADR adds adek_id-scoped shred variant and a shared-graph guard on top of it. -
Reaffirms ADR-001 (no 2PC — the saga + outbox is the sanctioned cross-service pattern), ADR-018 (the outbox this ADR extends with an event hold), and ADR-019 (the service identity the applications-only authz keys off).
Context
finalize_draft (services/canopy-applications/src/api/mod.rs) builds the applicant’s person →
household → membership → income/asset/expense graph in canopy-persons through ~6+ separate HTTP calls
before it opens the local transaction, locks the draft, inserts the applications row (the reserved
draft id as PK), stages outbox events, deletes the draft, and commits. No idempotency ties the persons
writes to the reserved application id.
Every interruption therefore orphans PII in canopy-persons with no owning application:
-
crash / 5xx mid-graph;
-
the reaper wins between the persons writes and the draft lock (a late lock-miss 404s);
-
a losing concurrent racer — both build the graph, the loser 404s with its graph stranded;
-
a double-submit / retry re-creates the graph.
This is a data-integrity + PII-hygiene defect in a federal eligibility system (GitLab #1005).
Why not the generic idempotency middleware. An obvious fix is to route the persons calls through the
existing idempotency middleware (crates/canopy-api/src/idempotency.rs). That is wrong on two counts the
middleware documents about itself. First, it is "exactly-once happy path / at-least-once on crash": the
domain transaction commits before the response-cache row is written, so a crash in that window re-executes
the write, and the 24h TTL re-executes it after expiry — neither is exactly-once, which is precisely the
guarantee finalize needs. Second, it caches raw response bodies, which for the persons create calls are
plaintext PII (names, DOB), landing PII in a generic cache outside crypto-shred. The correct layer for
finalize idempotency is the owning service, transactionally, storing only ids.
Decision
1. Idempotency is a persons-side transactional receipt
Each finalize-tagged persons write records a finalize_receipts(operation_id, generation, step_key) row
in the same transaction as the entity it creates and the outbox event it stages. operation_id is the
reserved application id; step_key is a deterministic StepKey (person(0), household(), member(i),
income(j), …). On ON CONFLICT (op,gen,step) DO NOTHING the handler reads and returns the stored
stable id, so a replay is a no-op that yields the original entity. The stable id is the
correction-surviving id (fact_id for facts, person_id/household_id for those), never a version_id.
Because the receipt, the entity, and the event commit atomically, there is no crash window in which the
write happened but the idempotency record did not.
2. An operation generation scopes every attempt
finalize_operation_generations(operation_id, generation, state active|cancelled) records the live
generation. Every finalize-tagged write, in its own transaction, takes FOR SHARE on the generation row
and refuses (409/410) if it is absent or cancelled — closing the "a write from a compensated attempt
lands after compensation" race. The generation is bumped on an aborted re-submit, so a fresh attempt’s
receipts/tags never collide with the aborted attempt’s, and the aborted attempt becomes a new filing
(fresh received_at/valid_from) — the aborted attempt created no application, so the successful
re-submit is the filing.
3. A linearizable, draft-row-locking saga governs recovery
A durable finalize_operations row (in_progress → completed | aborted, via compensating) holds the
pinned basis_date, received_at, keyed request_digest, a lease (lease_holder + lease_expires_at),
and — at completion — the household_id. claim_or_resume is one short transaction that locks the draft
row FOR UPDATE (serialising with the ADR-026 §6 reaper) then the op row FOR UPDATE, and branches on the
locked state: fresh insert, return the completed response, refuse a live/compensating op (503
Retry-After), steal an expired lease, or bump the generation on an aborted re-submit. Row locking — not a
snapshot CTE — makes concurrent claims linearizable.
No lock or transaction is held across a network call (#1005 criterion g): the lease is a heartbeat’d row
value, and the only transaction is the network-free final commit that inserts the application, deletes the
draft, and marks the operation completed — all lease-fenced, so a stolen lease cannot double-complete.
On a genuine 23505 the authoritative applications.household_id is read back and returned.
4. A keyed request digest binds the saga to the full request
request_digest is a keyed HMAC over a deterministic canonical serialisation of the full typed
FinalizeRequest, pinned per (operation_id, generation) and re-validated on every resume (mismatch ⇒
409). Keyed ⇒ not offline-guessable from the PII it covers. This closes the "resume with an edited or
reordered request skips already-receipted steps and runs the rest against different data" hole; a
legitimate portal resend recomputes the identical digest.
5. Compensation is crypto-shred, never a hard delete, with a shared-graph quarantine
A partial graph is undone by shredding each exclusively-owned entity’s DEK (ADR-036 tombstone) and
deactivating it — under lock_fact(fact_id) / a person_id advisory lock, scoped to the dek_id
captured at inventory (a new shred_with_dek_id variant, never shred_with(subject_kind, subject_id),
which would match every live DEK for the subject and could destroy a later legitimate correction). An
entity that cannot be proven exclusively owned under the lock (a fact with a later non-finalize version, a
person in another active household) is terminally quarantined — left intact, recorded for a data
steward — and never blocks the operation reaching aborted. Hard deletion is impossible by construction
(`redaction_keys’ one-way trigger; version-row FKs) and is not attempted.
6. Persons finalize events are held until the application commits
The persons event_outbox gains hold_operation_id / hold_generation; the drainer skips held rows
(AND hold_operation_id IS NULL). Finalize graph events are staged held and released (un-held) only
after the application commits, or dropped on compensation. Downstream therefore never observes the events
of a partial or compensated finalize. The hold does not order events across services:
application.submitted (the applications outbox) and the released persons events have no guaranteed
relative order, as for all cross-service events on the bus. Because the persons rows are committed
synchronously before either event drains, a consumer can always resolve a referenced entity by a
synchronous lookup even if it has not yet seen that entity’s event; finalize-graph consumers are verified
order-independent at MR0 and any order-dependent consumer is escalated, not silently relied upon.
7. The persons finalize surface is applications-only
The register / release / cancel / get internal endpoints — one of which can shred PII — are gated
by require_service_caller()? then claims.service_id() == Some("canopy-applications"). The
role-derived check follows the coarse one because service_id() falls back to azp, so gating on it alone
would admit an azp-only OIDC client that require_service_caller rejects — a weaker trust class on a
shred-capable endpoint. This is the codebase’s first specific-caller allow-list. Existing-orphan
remediation (MR9) is gated to require_data_steward().
Consequences
-
New persons tables (
finalize_operation_generations,finalize_receipts), new applications tables (finalize_operations,finalize_steps), and additiveevent_outboxhold columns (ADR-018 outbox, ADR-016 forward-only). All new columns/parameters are optional / NULL-defaulted, so non-finalize callers and existing outbox rows are unaffected. -
A new persons internal control surface + a
shred_with_dek_idredaction variant; a newcanopy-applications.finalize-reconcilerleader-elected scheduler; a shared lease-guard incanopy-db. -
The saga ships behind a feature flag; the cross-service acceptance suite (MR8) flips it on only once the persons foundation (MR1/MR2) is deployed, so an older persons deployment silently ignoring the finalize tag cannot occur while the flag is on. Merge order ≠ deploy order — the flag is the gate.
-
InProgressElsewheresurfaces as 503 + Retry-After, not 409: the generic idempotency middleware caches 409s for 24h and the portal BFF maps 409→502 and drops the Retry-After header, so 409 would poison a legitimate retry. -
A completed operation is prunable only once its events are released; the pruner must never remove a
completed && !events_releasedop (that would strand held persons events forever). This is alarmed. -
A one-shot
cargo xtask sweep-finalize-orphansremediates pre-fix orphans (manifest + quiescence
revalidation + live-op exclusion + resumable + dry-run default + PII-free), realising ADR-025’s deferred sweep-orphans follow-up for the finalize case.
Threat model
The design adds no secret and no obscurity (Kerckhoffs). The request digest is keyed with a server secret, so it is not offline-guessable from the PII it covers, but it is an integrity check, not an access control. The applications-only gate narrows trust on the shred-capable endpoints relative to today (no such endpoints existed). Compensation can only shred (tombstone) data, never expose it, and refuses to shred anything it cannot prove exclusively owned — the failure mode is "leaves data intact for a human," never "destroys a legitimate record." PII never enters a generic cache (the whole reason the receipt lives in persons, storing only ids). Logs and metrics on the saga/reconciler/sweep carry ids and counts only.
Alternatives considered
-
Generic idempotency middleware for the persons calls. Rejected — at-least-once on crash + a 24h re-exec (not exactly-once) and it caches plaintext PII. See Context.
-
Two-phase commit / a distributed transaction across applications + persons. Rejected — barred by ADR-001 (independent databases); the saga + transactional-outbox pattern is the sanctioned approach.
-
Hard-delete compensation (delete the persons rows on failure). Rejected — impossible by construction (the
redaction_keysone-way trigger, version-row FKs) and wrong in principle (ADR-036 forbids it); crypto-shred is the only PII-removal primitive. -
Server-minted id as the idempotency key. Rejected — entity ids are minted mid-handler, so the retry key must be the caller-supplied
(op, gen, step), with the receipt returning the persisted id. -
A snapshot-CTE claim (read-then-write without row locks). Rejected — not linearizable; concurrent claimers can both observe "no live op." The claim locks the op row
FOR UPDATE. -
Compensating client-reclaims (let a new request take over a
compensatingop). Rejected — compensation must be driven only by the fenced reconciler; a client reclaim would race the shred.