T2-4 — FTI-bearing Determination Snapshots + Program Fan-out (tanf/medicaid/caps/wic) (#685)
On this page
Epic &56 / Track 2, T2-4 (#685) —
ADR-028 §52. T1-10 (#678) gave
SNAP an immutable, signature-bound determination input snapshot; the snapshot
contract (crates/canopy-contracts-eligibility/src/snapshot.rs) was deliberately built
program-agnostic for this fan-out. T2-4 extends it to the four remaining program
services — tanf, medicaid, caps, wic — and makes the FTI-bearing snapshots
(tanf, medicaid) join the ADR-014 hash chain so FTI-at-rest gets the same IRS Pub
1075 §4 tamper-evidence + §9 breach pathway every other FTI artifact has. Shipped in
three MRs under #685. Closing #685 completes the program snapshot fan-out.
Scope boundary
T2-4 is snapshot capture + binding + storage for the 4 services + FTI chain-entry
for tanf/medicaid. Each snapshot captures what the service currently evaluates and the
context it currently receives. OUT of scope (named, each a follow-up or another Track-2
unit): cross-program input capture EE15/ELE/TMA/SOLQ-FDSH (T2-3 #684); the derivation
graph + per-rule versioning (T2-2 #679); appeals snapshot-replay + overpayment recalc
consumers (T2-8 #681); the orchestrator ProgramResult.snapshot_hash receipt (follow-up
#879); provenance-enrichment of the tanf/medicaid orchestrator→program wire (their
contexts carry person_id but not provenance today → v1 facts capture person_id
values with provenance: None); the contract phase of expand-contract (dropping the
superseded narrow tables — follow-up).
Status
| Step | Description | Status |
|---|---|---|
(plan) |
This execution plan + nav entry. |
Done (2026-06-20) — |
MR1 (shared) |
|
Done (2026-06-20) — |
MR1 (caps) |
|
Done (2026-06-20) — |
MR1 (wic) |
|
Done (2026-06-20) — |
MR1 (tests + docs) |
caps/wic integration (re-hash == signed hash; marker; immutability; corpus_hash present incl. denial); insta |
Done (2026-06-20) — the MR1 docs commit. |
MR2 (canopy-common) |
Extract |
Done (2026-06-21) — |
MR2 (tanf) |
|
Done (2026-06-21) — the MR2 implementation commit. |
MR2 (tests + docs) |
MR1 set + an |
Done (2026-06-21) — the MR2 implementation commit. |
MR3 (medicaid, FINAL) |
|
Done (2026-06-21) — the MR3 implementation commit. |
MR3 (tests + docs, FINAL) |
Per-member re-hash == signed hash; one |
Done (2026-06-21) — the MR3 implementation commit. |
Context
T1-10 froze SNAP’s determination inputs into an immutable, signature-bound snapshot
(SHA-256 over RFC 8785 canonical bytes → a signed snapshot_hash, the blob stored in an
append-only determination_snapshots table, the orchestrator receiving outcome + hash
only). The other four program services emit no snapshot, so once facts are valid-time
versioned + correctable (ADR-027) their determinations cannot answer "what did this
determination see?" — the same appeals/QC gap T1-10 closed for SNAP. T2-4 closes it for
tanf/medicaid/caps/wic.
ADR-028 §52 additionally requires that the FTI-bearing snapshots (canopy-tanf under
IRC §6103(l)(7), canopy-medicaid under §6103(l)(12)) join the ADR-014 hash chain so
the FTI-derived artifact at rest carries the Pub 1075 §4 tamper-evidence + §9 breach
pathway. ADR-014’s chain lives in each FTI service’s fti_audit_log table
(crates/canopy-common/src/fti_audit.rs), with verify_fti_chain, the canopy-security
daily verify job, and the breach pathway already in place — so "join the chain" reuses
that entire apparatus by appending one fti_audit_log entry per FTI snapshot creation.
Decisions
| Decision | Resolution |
|---|---|
Contract reuse |
|
CAPS/WIC facts (no fact arrays) |
|
TANF facts |
|
Medicaid facts |
|
Per-subject → N snapshots |
medicaid/caps/wic emit N determinations → N |
|
CAPS |
|
|
|
CAPS: rules |
Wrapper-client plumbing |
|
FTI chain entry (tanf/medicaid) |
New |
FTI |
|
Carrier for pre-signed snapshot |
caps/wic/medicaid sign in the compute phase but persist later → add |
Storage + tx |
TANF: refactor |
Migrations (ADR-016 expand) |
Per service, next free slot ≥ |
Supersession |
Additive (expand only). |
Legacy marker |
|
Byte-stability |
|
Other services + verifier + orchestrator |
No change. Tolerance via |
Implementation
Three MRs under #685 (MR1/MR2 Relates to, MR3 Closes); each commit independently
build-green; per-commit the pre-commit token gate + a fresh J1–J8 subagent over the
staged diff, reported as text.
MR1 — CAPS + WIC (non-FTI fan-out)
-
Shared —
SnapshotStatusincanopy-contracts-eligibility;arb_program→ all sixProgramvariants. -
CAPS (
services/canopy-caps,crates/canopy-contracts-caps) — deps (serde_jcs/sha2/canopy-contracts-eligibilityon the service;canopy-contracts-eligibilityon the contract forSnapshotStatus); single pre-loopevaluate_with_corpus_hashreused per child;caps_program_input+build_caps_policy_params(STRUCTURAL-VALUE markers); per-child assemble →canonical_hash()→envelope.snapshot_hash = Some(hash)before sign →jws_kid;DetermineOutputcarrier; handler-txcreate_determination_snapshot; column + immutable table + trigger;CapsDeterminationReadmarker on GET + list. -
WIC — same, per-participant;
wic_program_input+build_wic_policy_params. -
Tests (mirror
snap_test.rs, dedicatedCANOPY_PORT_POSTGRES_{CAPS,WIC}_5432DBs) — re-hash == signed hash;snapshot_statusPresent + legacy NoInputSnapshot; immutability (UPDATE/DELETE RAISE, GUC-gated succeeds); corpus_hash present (incl. on a denial); insta.snapshot_hash ⇒ "[HASH]"redaction. -
Docs — caps/wic
api/+data-models/;CHANGELOG; this plan’s MR1 cells; master-plan T2-4 row →In progress; regen caps/wic OpenAPI.
MR2 — TANF (FTI + ADR-014 chain entry)
-
canopy-common — extract
insert_fti_chain_entry;log_accessbecomes its wrapper; chain test passes unchanged. -
TANF — deps;
TanfRulesClient::evaluate_eligibility_with_corpus_hash(alwaysSome);tanf_program_input+build_tanf_policy_params; facts incl.assetsleaves (no person_id);accessed_byfrom handler claims; refactorcreate_determination→&mut PgConnection(update all callers); persist det → snapshot →insert_fti_chain_entry(&mut tx, "canopy-tanf", …)(purposeTanfEligibility, actionWrite, data_elements = names) last before commit; keepcreate_household_snapshot; column + table + trigger;TanfDeterminationReadmarker. -
Tests — MR1 set + an
fti_audit_logdetermination_snapshotentry is emitted andverify_fti_chainstill passes; corpus_hashSomeon a time-limit denial. -
Docs — tanf
api/`data-models/` ( chain-entry behavior);CHANGELOG; plan MR2 cells; regen tanf OpenAPI.
MR3 — Medicaid (per-member FTI), Closes #685
-
Medicaid — deps;
MedicaidRulesClient::evaluate_cmd_cascade_priority_with_corpus_hash(capture once inresolve_priority_order, reuse for all subjects);medicaid_program_input(cascade order + per-COA evals + denial evals + countable_resources + SOLQ) +build_medicaid_policy_params; facts (income person_id; expenses + assets none);DetermineOutputcarrier;accessed_bythreaded topersist_determinations; in the single all-or-nothing tx create the application + N determinations + N snapshots, then append Ninsert_fti_chain_entry(&mut *tx, "canopy-medicaid", …)last before commit; column + table + trigger;MedicaidDeterminationReadmarker. -
Tests — per-member re-hash == signed hash; one
fti_audit_logentry per determination; chain verifies. -
Docs — medicaid
api/+data-models/;CHANGELOG; this plan →Done+ As-built; master-plan T2-4 row →Done; regen medicaid OpenAPI; close #685 + closing comment.
Snapshot types
Reused verbatim from T1-10 — see
the T1-10 plan
(DeterminationSnapshot / SnapshotFacts / the typed fact leaves / HouseholdComposition
/ MemberLeaf). T2-4 adds no new snapshot type; only the per-service program_input /
policy_params builders and the shared SnapshotStatus enum.
Verification
Per MR: cargo build + cargo clippy -p <crate> --all-targets — -D warnings + targeted
nextest; then cargo xtask dev refresh → cargo nextest run -p canopy-{svc} -p
canopy-contracts-eligibility -p canopy-common. Before push: full cargo xtask validate
+ cargo xtask api-docs --update + cargo xtask quality-budgets (the two STRUCTURAL-VALUE
fields per service need single-line markers; a lock bump is surfaced-and-decided per
ADR-030, never silent). Load-bearing assertions: stored snapshot re-hashes to the
durably-stored signed {prog}_determinations.snapshot_hash; row immutable (UPDATE/DELETE
rejected, GUC allowed); corpus_hash present on every determination incl. denials;
per-subject services produce one snapshot per determination; tanf/medicaid emit a
determination_snapshot fti_audit_log entry within the persist tx and verify_fti_chain
still passes; the snapshot blob is program-side only (orchestrator gets the signed
envelope, hash not surfaced/persisted until #879); legacy → no_input_snapshot; other
services + verifier byte-unchanged.
As-built notes
Built as three MRs under #685, all force-merged after a green local battery (CI
permanently broken): MR1 (CAPS + WIC, Relates to #685), MR2 (TANF,
Relates to #685), MR3 (Medicaid, Closes #685). The plan held; the deviations
below are mechanical (mostly keeping the quality budgets flat) and changed no
behaviour.
-
insert_fti_chain_entrysignature. Landed asinsert_fti_chain_entry(conn: &mut PgConnection, entry: &FtiAuditEntry)— theoriginating_systemis read offentry.originating_systemrather than passed separately (the plan sketched a 3-arg form). Pool-basedlog_accessis now a thinbegin → insert_fti_chain_entry → commitwrapper; the pre-existingfti_audit_hash_chain_test.rspasses unchanged. -
program_input/policy_paramsfor the FTI services built inline viaserde_json::json!. TANF and Medicaid build these two STRUCTURAL-VALUE blobs with inlineserde_json::json!(…)at the assembly site rather than through a named{prog}_program_input(…)helper.json!/from_valuedo not match the B3aserde_json::Valuetext-grep, so no STRUCTURAL-VALUE marker (and no budget bump) was needed for them. CAPS/WIC (MR1) kept the helper form with single-line// STRUCTURAL-VALUE:markers. -
Medicaid corpus_hash source. Captured from the always-run abd
evaluate_cmd_cascade_priority_with_corpus_hashcall insideresolve_priority_order(which returns(Vec<MedicaidCategory>, String)), then reused for every member’s snapshot — never from the EE15 hierarchy, which is skipped on the full-denial/no-COA path. The new wrapper method is self-contained (it builds the namespaced envelope + calls the genericevaluate_with_corpus_hashdirectly) sodispatchwas left untouched, again to keep B3a flat. -
Medicaid cohesion extractions. To stay within the LOCKED budgets,
determinebundles the caller identity intoCallerContext { bearer_token, accessed_by }(≤7 args), and the per-snapshot FTI entry is built by a smallmedicaid_snapshot_chain_entry(accessed_by, determination_id)helper sopersist_determinationsstays under the >100-LOC threshold.MemberFlagsgained#[derive(serde::Serialize)]to be embeddable inprogram_input. -
Quality budgets. Flat across all three MRs (B2 123, B3a 757, B4 137, B5 309) — no lock bump was required.
-
Supersession. Additive only, as planned:
tanf_household_snapshotsis still written (read-nowhere);magi_household_snapshotshas no active write/read path and was left untouched. Dropping the dead tables is the filed contract-phase follow-up. -
Orchestrator. Unchanged. The
snapshot_hashis bound into each service’s signed envelope (which the orchestrator already tolerates), butProgramResultcarries nosnapshot_hashfield, so it is not surfaced in the orchestrator response nor persisted there until #879 lands. T2-4’s durable storage is program-service-side (the{prog}_determinations.snapshot_hashcolumn + thedetermination_snapshotsblob), which is the delivered guarantee.
Follow-ups
File each as a separate GitLab issue and /relate #685:
-
chore:expand-contract contract phase — once confirmed dead, stop writing + droptanf_household_snapshots(+magi_household_snapshots). -
feat:provenance-enrichment of the tanf/medicaid orchestrator→program wire so their snapshot facts carryprovenancelike SNAP (currentlyNone). -
chore:consolidateSnapshotStatus— migrate SNAP’scanopy-contracts-snapenum to the sharedcanopy-contracts-eligibilityone (optional). -
(Already filed in T1-10:
ProgramResult.snapshot_hashreceipt #879; §39 policy-version stamp; §53 key-retention; §57 supersession; #878 dead-signer delete.)