T2-1 Half B — Determination supersession (canopy-snap + shared envelope) (#683)
On this page
Epic &56 / Track 2, T2-1 (#683), slice Half B — the closing MR. T1-10 (#678)
T2-4 (#685) made every program determination freeze an immutable, signature-bound
input snapshot (ADR-028). Half A
(A1 addresses + A2 household_members) brought the remaining facts to valid-time parity.
Half B implements ADR-028 §57 — determination supersession: a re-determination records
the previous_determination_id it supersedes; prior determinations + snapshots stay
immutable + queryable, flagged superseded-as-of; and a program-service read endpoint
exposes a determination’s frozen snapshot for the cross-service materiality/overpayment
callers. Half B closes #683 (and implements #882, the snapshot-read endpoint, for SNAP).
Scope boundary
Half B is the supersession substrate + the snapshot-read endpoint, in canopy-snap
the shared canopy-signing envelope. The substrate: a signed previous_determination_id
link on the universal SignableDetermination; a nullable snap_determinations column;
a derived superseded-by/as-of read (no mutation of the immutable determination); and
GET /v1/determinations/{id}/snapshot returning the frozen DeterminationSnapshot.
OUT of scope (each a deferral with a recorded reason):
-
The production supersession trigger — the orchestrator resolving the operative antecedent for a case on a recert/adjustment, threading it through the eligibility
ApplicationContext, and persisting it onprogram_determinations. This is T2-7 (#680): only the case-lifecycle owner can correctly decide a re-determination is happening (vs. a retry, a first determination, or a second application). Until then production determinations carryprevious_determination_id = None— by design, no false chains. Annotated on #680. -
Materiality diff (ADR-027 §6) → T2-7 #680; overpayment recalc / appeals replay → T2-8 #681. Half B is the substrate they consume.
-
FTI programs (tanf/medicaid) supersession + their hearing-scoped, in-boundary snapshot-read (ADR-028 §70 / ADR-004) + their FTI snapshots' ADR-014 chain entry → FTI follow-up. SNAP is the only program that is both non-FTI and orchestrator-live.
-
caps/wic supersession → deferred until they are orchestrator-reachable (today
map_contextreturnsInputUnsatisfiable). -
DeterminationSnapshotutoipa::ToSchema(full nested schema) —body = Object.
Status
| Step | Description | Status |
|---|---|---|
(plan) |
This execution plan + nav entry. |
Done (2026-06-22) — the plan commit. |
shared field + snap contracts |
|
Done (2026-06-22) — the Half B implementation commit. |
snap migration + store |
|
Done (2026-06-22) — the Half B implementation commit. |
snap determine + API |
integrity guard FIRST (before |
Done (2026-06-22) — the Half B implementation commit. |
test-lib + CLI |
|
Done (2026-06-22) — the Half B implementation commit. |
tests |
signing tolerance (absent/present); snap integration (link persists+signs+verifies; derived superseded-by + |
Done (2026-06-22) — the Half B implementation commit. |
docs |
|
Done (2026-06-22) — the Half B implementation commit. |
Context
ADR-028 §57: "a new determination (an adjustment re-determination or a recert) records
the previous_determination_id it supersedes and the effective period it governs. Prior
determinations + their snapshots remain immutable and queryable, flagged superseded-as-of.
The materiality check diffs against the operative (latest non-superseded for the date)
snapshot; overpayment recalc walks the chain. A program-service read endpoint exposes a
determination’s frozen snapshot … for the cross-service materiality/overpayment callers."
Today: snap_determinations (cols incl. effective_date/expiration_date = the
certification window, snapshot_hash from T1-10) has no supersession concept; the
append-only immutable determination_snapshots blob has no read endpoint (only the
signed snapshot_hash on the determination row + the derived SnapshotStatus). canopy-snap
has no idempotency dedup — every POST /v1/determine writes a fresh determination. SNAP
determinations are household-level (SignableDetermination::build sets person_id =
None). No re-determination caller exists anywhere (renewals only marks change-reports; the
recert→determine wiring is T2-7 #680, unbuilt).
The "effective period it governs" is the existing cert window (effective_date →
expiration_date); no new governing-period columns. "superseded-as-of" is derived (the
superseding determination’s effective_date), never a stored mutation of the immutable row.
Decisions
| Decision | Resolution |
|---|---|
A — explicit/optional link, trigger → T2-7 |
§57 says the producer records a known antecedent — it does not infer "latest for household." Auto-resolution in the program service is rejected (no idempotency dedup → retries; two applications per household; a denial after an approval; and it would corrupt T2-7’s operative-snapshot diff). The antecedent can only be resolved by the case-lifecycle owner on a deliberate recert/adjustment = T2-7 (#680). So Half B ships the substrate: |
B — shared code all programs; feature SNAP-only on an architectural boundary |
The shared |
Shared signed field |
|
Migration |
Forward-only |
Contracts |
|
Store |
|
Determine flow |
Integrity guard runs FIRST — after |
API |
|
test-lib + CLI |
|
Quality budgets |
Typed JSONB read ⇒ B3a flat; |
OpenAPI |
|
Implementation
Single MR, Closes #683 (Closes #882 if #882 is snap-scoped, else Relates to #882).
The (plan) commit is first (this .adoc + nav). Each commit build-green; per-commit the
pre-commit token gate + a fresh J1–J8 subagent over the staged diff, reported as text.
-
Shared signed field —
canopy-signing/src/envelope.rsfield +build()init + 2 tolerance tests. -
snap contracts —
canopy-contracts-snapdetermine.rs/models.rs/paths.rs+ roundtrip proptests. -
snap migration — the
ALTER+ index. -
snap store —
models.rsrow +From;mod.rsbind +get_determination_snapshot+get_superseding. -
snap determine — integrity guard + set
previous_determination_idbefore signing + the literal field. -
snap API —
get_determinationsuperseding lookup;get_determination_snapshothandler; route/#[openapi]/20→21. -
test-lib + CLI —
SnapClient::get_determination_snapshot;canopy snap determination snapshot. -
Tests — signing tolerance; snap integration (link persists+signs+verifies; derived superseded-by; chain-of-three; snapshot read re-hashes to
snapshot_hash; 404 legacy; RBAC; integrity guard); eligibility verify; contracts roundtrip. -
Docs —
data-models/apicanopy-snap;api-docs --update; CHANGELOG (Added/Changed); master plan T2-1 → Done; this plan → Done + As-built; annotate T2-7 #680; file follow-ups.
Verification
cargo build -p canopy-signing -p canopy-contracts-snap -p canopy-snap -p canopy-test-lib
-p canopy-eligibility -p canopy-cli. set -a; source .ports.env; set +a; cargo nextest run
-p canopy-snap -p canopy-signing -p canopy-contracts-snap (snap on its dedicated postgres
a service token). cargo xtask quality-budgets (expect flat). cargo xtask api-docs
--update + commit all program snapshot deltas + confirm snap path count 21. cargo xtask
docs plan-lint + cargo xtask check-docs. After cargo xtask dev refresh: a seeded SNAP
determination’s GET /v1/determinations/{id}/snapshot returns its frozen snapshot;
superseded_* are None for a single determination. Full pre-push battery (cargo xtask
validate --skip-docker + e2e + cargo doc + k6 smoke). Load-bearing assertions: a
re-determination carrying previous_determination_id persists + signs + verifies with the
link; GET /determinations/{D1} shows superseded_by_id = D2 + superseded_as_of =
D2’s effective_date (approval) and D2’s snapshot as_of when D2 is a denial
(effective_date = None); a second determination superseding D1 → 409 (the one-to-one
partial-unique index); the snapshot-read blob re-hashes (serde_jcs) to the signed
snapshot_hash; a missing determination 404s; a legacy (snapshot_hash IS NULL)
determination 404s with the "no input snapshot" message (distinct from a corrupt
snapshot_hash-set-but-blob-missing → 500); a below-service-and-non-QC caller is 403; a
cross-household / non-existent antecedent 422s.
As-built notes
Built as planned (plan commit + one implementation commit), Closes #683. The
plan held end-to-end; the deviations below are minor and recorded so the
plan↔code diff stays zero.
-
The eligibility "verify" test landed at the signing layer (not a new canopy-eligibility test). The plan called for "one canopy-eligibility verification test." The honest, non-redundant home is
canopy-signing/src/lib.rs::supersession_link_is_signed_and_tamper_evident: it signs aSignableDeterminationcarryingprevious_determination_id, verifies it through the same verify-detached-over-canonical_signing_payloadpath the orchestrator uses, and proves a tampered link fails verification. The orchestrator’s verify is generic over that payload, so this is the eligibility-side guarantee — a separate eligibility test would only re-prove canopy-signing’s coverage. -
Supersession read = a
LEFT JOINin both the single + list store reads (no separateget_supersedinghelper). The plan sketched a standaloneget_superseding; the implementation folds the superseder + its snapshotas_ofinto oneLEFT JOIN(DETERMINATION_READ_SELECT) used by bothget_determinationandlist_determinations, mapped toSnapDeterminationReadvia a#[sqlx(flatten)]SnapDeterminationReadRow. So both reads are accurate and there is no N+1. -
body = Objectfor the snapshot endpoint. The sharedDeterminationSnapshothas noutoipa::ToSchema; documenting the full nested schema would be an 8-struct derive sweep out of scope here, so the 200 body isObjectin the#[utoipa::path]. -
ADR-028 unchanged. Half B fulfils §57 as written; no ADR edit.
-
Decision A annotation landed on T2-7 (#680). The production trigger
orchestrator persistence hand-off is recorded there; production determinations carryprevious_determination_id = Noneuntil T2-7 wires it.
Follow-ups
File each as a separate GitLab issue and /relate #683:
-
FTI supersession + hearing-scoped in-boundary snapshot-read (tanf/medicaid) — bundle with T2-8 #681 / #884; includes their FTI snapshots' ADR-014 chain entry.
-
caps/wic supersession — when they become orchestrator-reachable (relate #862).
-
orchestrator-side
previous_determination_idreceipt (program_determinationscolumn
ProgramResult) — fold into T2-7 #680 (mirrors #879); this is the production trigger’s home.