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 on program_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 carry previous_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_context returns InputUnsatisfiable).

  • DeterminationSnapshot utoipa::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

SignableDetermination.previous_determination_id (signed, skip-if-none) + build() init + tolerance tests; ApplicationContext/SnapDetermination/SnapDeterminationRead (superseded_* + with_supersession) + GET_DETERMINATION_SNAPSHOT path + roundtrip.

Done (2026-06-22) — the Half B implementation commit.

snap migration + store

ALTER snap_determinations ADD previous_determination_id (self-FK) + partial UNIQUE index (one-to-one chain); SnapDeterminationRow + From + SnapDeterminationReadRow; create_snap_determination bind; get_determination_snapshot; supersession LEFT JOIN (COALESCE(effective_date, snapshot.as_of)) in both the single read + the list.

Done (2026-06-22) — the Half B implementation commit.

snap determine + API

integrity guard FIRST (before create_snap_application; non-existent/cross-household antecedent → 422, already-superseded → 409, 23505 backstop) + set previous_determination_id before signing; get_determination/listwith_supersession; new get_determination_snapshot handler (service-or-admin/QC auth; tri-state 404 missing / 404 legacy / 500 corruption); route/#[openapi]/path-count 20→21.

Done (2026-06-22) — the Half B implementation commit.

test-lib + CLI

SnapClient::get_determination_snapshot + path-sub test; list helper → Vec<SnapDeterminationRead>; canopy snap determination snapshot --id.

Done (2026-06-22) — the Half B implementation commit.

tests

signing tolerance (absent/present); snap integration (link persists+signs+verifies; derived superseded-by + superseded_as_of for an approval AND a denial superseder; one-to-one (double-supersede → 409); chain-of-three; snapshot read re-hashes to snapshot_hash; 404 missing / 404 legacy / RBAC below-service+QC; integrity guard 422); eligibility verify; contracts roundtrip.

Done (2026-06-22) — the Half B implementation commit.

docs

data-models/api canopy-snap; api-docs --update (all program snapshots, snap 20→21); CHANGELOG (Added/Changed); master plan T2-1 → Done; this plan → Done + As-built; annotate T2-7 #680; file follow-ups.

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_dateexpiration_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: previous_determination_id is a signed Option on SignableDetermination (set from the snap ApplicationContext before signing). No orchestrator program_determinations column and no DetermineRequest.supersedes API — an orchestrator column would only ever be written None until T2-7 (storage with no writer); a request field would be speculative API T2-7 may reshape. Deferral mirrors #879 (the orchestrator-side snapshot_hash receipt was deferred for the same reason). The snap field IS exercised non-None by direct snap tests; the orchestrator’s JWS verify signs-over it automatically (one eligibility verify test). Annotate #680 (+ relate #851/#792).

B — shared code all programs; feature SNAP-only on an architectural boundary

The shared canopy-signing field is done once (all five programs serialize/verify it; the /determine OpenAPI of every program gains the optional field). The program-specific feature is SNAP-only because SNAP is the only program that is both non-FTI and orchestrator-live: tanf/medicaid are FTI (their snapshot-read needs a hearing-scoped in-boundary auth model + ADR-014 chain entry — a T2-8 concern); caps/wic are not orchestrator-reachable (map_contextInputUnsatisfiable). The shared field being None in the four deferred programs is the established snapshot_hash pattern (None in 4 programs T1-10→T2-4), not a fig leaf.

Shared signed field

crates/canopy-signing/src/envelope.rs: previous_determination_id: Option<DeterminationId> after snapshot_hash with #[serde(skip_serializing_if = "Option::is_none")]; init None in build() (no new positional arg — set on the mut envelope like snapshot_hash). Two tolerance tests (absent → dropped from wire + canonical; present → round-trips + signed-over), mirroring the snapshot_hash tests.

Migration

Forward-only services/canopy-snap/migrations/20260622000000_add_previous_determination_id.sql: ALTER TABLE snap_determinations ADD COLUMN IF NOT EXISTS previous_determination_id UUID REFERENCES snap_determinations(id); + a partial UNIQUE index enforcing a one-to-one (linear) chain: CREATE UNIQUE INDEX IF NOT EXISTS idx_snap_determinations_previous_unique ON snap_determinations(previous_determination_id) WHERE previous_determination_id IS NOT NULL; — at most one determination supersedes a given prior (no branching; a concurrent double-supersede → 23505 → 409). Nullable (first determination + legacy = NULL). Supersession is derived — prior rows are never updated, so snap_determinations is append-only-by-convention + signature-tamper-evident (the DB append-only trigger is only on determination_snapshots, per the T1-10 precedent; DB-level immutability of the determination row is a pre-existing gap, out of scope).

Contracts

canopy-contracts-snap determine.rs ApplicationContext: previous_determination_id: Option<DeterminationId> (serde(default, skip_serializing_if)). models.rs: SnapDetermination gains the field; SnapDeterminationRead gains derived superseded_by_id/superseded_as_of (NOT stored) + a with_supersession(det, superseding) ctor. Both get and list populate supersession (no misleading absent fields on list rows); new() survives only as the with_supersession(det, None) shorthand for genuinely-unresolved paths. paths.rs: GET_DETERMINATION_SNAPSHOT = "/v1/determinations/{id}/snapshot".

Store

store/models.rs row + From + a SnapDeterminationReadRow (FromRow with superseded_by_id/superseded_as_of); store/mod.rs create_snap_determination adds the column + bind. Supersession computed in BOTH the single read and the list via a LEFT JOIN to the superseder + its snapshot: … FROM snap_determinations d LEFT JOIN snap_determinations sup ON sup.previous_determination_id = d.id LEFT JOIN determination_snapshots s ON s.determination_id = sup.id selecting d.*, sup.id AS superseded_by_id, COALESCE(sup.effective_date, s.as_of) AS superseded_as_of. superseded_as_of = COALESCE(superseder.effective_date, superseder-snapshot.as_of) — NOT effective_date alone (a denial superseder has effective_date = None, determine.rs:399; the snapshot as_of is the universal fallback; legacy superseder w/o snapshot → None). The 1:1 partial-unique index guarantees LEFT JOIN sup is ≤1 row. get_determination_snapshot(pool, id) (SELECT snapshot … via sqlx::types::Json<DeterminationSnapshot> — typed, no serde_json::Value; fetch_optional). list_determinations_for_export unchanged (raw rows; supersession out of its scope).

Determine flow

Integrity guard runs FIRST — after household_size == 0 (:171-175) and before create_snap_application (:177) so a bad antecedent 422s with no orphan application row: if context.previous_determination_id is Some(prev), verify it exists AND household_id matches → 422; a not-already-superseded check → 409 (the partial-unique index is the race-safe backstop, 23505 → 409 at persist). Set envelope.previous_determination_id before signing; add to the SnapDetermination literal. No latest-for-household resolution.

API

get_determination + list_determinations map store rows through with_supersession (both accurate). New get_determination_snapshotleast-privilege (full-PII blob): service OR admin/quality_control, not general caseworker (require_service_caller().or_else(|_| require_admin_or_quality_control())). Tri-state: determination missing → 404; snapshot_hash IS NULL → 404 "no input snapshot (legacy)"; snapshot_hash set but blob missing → 500 + tracing::error (corruption, never silent); else Json<DeterminationSnapshot>. [utoipa::path] body = Object. Register route + [openapi]; bump path-count 20→21.

test-lib + CLI

SnapClient::get_determination_snapshot + path-sub test; the list helper returns Vec<SnapDeterminationRead> (keep snapshot_status + superseded_* regression-testable). canopy snap determination snapshot --id (new snap group + cmd/snap.rs, service/QC token) + cli_test roundtrip.

Quality budgets

Typed JSONB read ⇒ B3a flat; fetch_optionalok_or_else(NotFound) (no unwrap_or_default) ⇒ B5 flat. Ratchet DOWN on a clean cluster; surface any rise (ADR-030).

OpenAPI

api-docs --update regenerates every program’s /determine response (the shared field) + the new snap path + the superseded_* fields. Snap path count 20→21.

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.

  1. Shared signed fieldcanopy-signing/src/envelope.rs field + build() init + 2 tolerance tests.

  2. snap contractscanopy-contracts-snap determine.rs/models.rs/paths.rs + roundtrip proptests.

  3. snap migration — the ALTER + index.

  4. snap storemodels.rs row + From; mod.rs bind + get_determination_snapshot + get_superseding.

  5. snap determine — integrity guard + set previous_determination_id before signing + the literal field.

  6. snap APIget_determination superseding lookup; get_determination_snapshot handler; route/#[openapi]/20→21.

  7. test-lib + CLISnapClient::get_determination_snapshot; canopy snap determination snapshot.

  8. 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.

  9. Docsdata-models/api canopy-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 a SignableDetermination carrying previous_determination_id, verifies it through the same verify-detached-over-canonical_signing_payload path 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 JOIN in both the single + list store reads (no separate get_superseding helper). The plan sketched a standalone get_superseding; the implementation folds the superseder + its snapshot as_of into one LEFT JOIN (DETERMINATION_READ_SELECT) used by both get_determination and list_determinations, mapped to SnapDeterminationRead via a #[sqlx(flatten)] SnapDeterminationReadRow. So both reads are accurate and there is no N+1.

  • body = Object for the snapshot endpoint. The shared DeterminationSnapshot has no utoipa::ToSchema; documenting the full nested schema would be an 8-struct derive sweep out of scope here, so the 200 body is Object in 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 carry previous_determination_id = None until 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_id receipt (program_determinations column
    ProgramResult) — fold into T2-7 #680 (mirrors #879); this is the production trigger’s home.

Edit this page · default