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) — 46117b0.

MR1 (shared)

canopy-contracts-eligibility — shared SnapshotStatus { Present, NoInputSnapshot } enum for the read wrappers; extend the snapshot proptest arb_program to all six Program variants.

Done (2026-06-20) — 0457849.

MR1 (caps)

canopy-caps — capture the single pre-loop evaluate’s corpus_hash (reuse for every child); caps_program_input + build_caps_policy_params; assemble + hash + bind + sign per child; DetermineOutput carrier (snapshot + signing_kid); create_determination_snapshot in the handler tx; caps_determinations.snapshot_hash column + immutable determination_snapshots table + trigger; CapsDeterminationRead marker on GET + list.

Done (2026-06-20) — 1a3d1ff7.

MR1 (wic)

canopy-wic — same pattern, per-participant (each participant’s evaluate corpus_hash); wic_program_input + build_wic_policy_params; carrier + storage + marker + migration.

Done (2026-06-20) — 4b9469c0.

MR1 (tests + docs)

caps/wic integration (re-hash == signed hash; marker; immutability; corpus_hash present incl. denial); insta .snapshot_hash redaction; api/+data-models/ for caps+wic; CHANGELOG; this plan’s MR1 cells; master-plan T2-4 row → In progress; regen caps/wic OpenAPI.

Done (2026-06-20) — the MR1 docs commit.

MR2 (canopy-common)

Extract insert_fti_chain_entry(&mut PgConnection, &FtiAuditEntry) from log_access (advisory-lock + chain-from + compute_fti_event_hash + INSERT … clock_timestamp()); pool-based log_access becomes a begin/insert/commit wrapper. Existing fti_audit_hash_chain_test.rs passes unchanged. (originating_system is read off the entry, so it is not a separate param.)

Done (2026-06-21) — 3abb9f9.

MR2 (tanf)

canopy-tanfTanfRulesClient::evaluate_eligibility_with_corpus_hash (always Some); program_input/policy_params built inline via serde_json::json! (no B3a); facts incl. assets leaves (no person_id) + members parsed from MemberContext; accessed_by from handler claims; refactor create_determination&mut PgConnection; persist det + snapshot + insert_fti_chain_entry (last, before commit) in one tx; column + table + trigger; TanfDeterminationRead marker.

Done (2026-06-21) — the MR2 implementation commit.

MR2 (tests + docs)

MR1 set + an fti_audit_log determination_snapshot entry is emitted and verify_fti_chain still passes; corpus_hash Some on a denial. tanf api/+data-models/; CHANGELOG; plan MR2 cells; regen tanf OpenAPI.

Done (2026-06-21) — the MR2 implementation commit.

MR3 (medicaid, FINAL)

canopy-medicaidevaluate_cmd_cascade_priority_with_corpus_hash (always-run, reuse for all subjects; NOT hierarchy); medicaid_program_input (cascade order + per-COA evals + denial evals + countable_resources + SOLQ) + build_medicaid_policy_params; facts (income person_id, expenses + assets none); DetermineOutput carrier; accessed_by threaded to persist_determinations; N snapshots + N insert_fti_chain_entry appended LAST in the single all-or-nothing tx; column + table + trigger; MedicaidDeterminationRead marker. Closes #685.

Done (2026-06-21) — the MR3 implementation commit.

MR3 (tests + docs, FINAL)

Per-member re-hash == signed hash; one fti_audit_log entry per determination; chain verifies. medicaid api/+data-models/; CHANGELOG; this plan → Done + As-built; master-plan T2-4 row → Done; regen medicaid OpenAPI; close #685 + closing comment.

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

DeterminationSnapshot + canonical_hash() + SignableDetermination.snapshot_hash + jws_kid reused unchanged (program-agnostic since T1-10). Only shared addition: a SnapshotStatus { Present, NoInputSnapshot } enum in canopy-contracts-eligibility for the 4 read wrappers. SNAP’s existing canopy-contracts-snap::SnapshotStatus is left as-is (optional consolidation follow-up).

CAPS/WIC facts (no fact arrays)

facts.income/assets/expenses = vec![] (no itemized facts); the scalar gross_monthly_income + activity/participant attributes ride in program_input (the exact rules_input), where reproducibility lives. facts.household.members = the per-subject children/participants. No synthetic leaves.

TANF facts

income leaves (person_id) + assets leaves from ctx.assets (AssetItem has no person_idperson_id: None) + expenses leaves (person_id opt); all fact_id/provenance None; money .rescale(2); household.members from the context.

Medicaid facts

income leaves (wire IncomeItem has person_id) + expenses leaves (household-summed medical, no person_id); facts.assets = vec![] — wire assets are present but ignored by the determination (uses ctx.countable_resources, TODO #856), captured in program_input instead; household.members = whole household (self-contained). One snapshot per member determination.

Per-subject → N snapshots

medicaid/caps/wic emit N determinations → N DeterminationSnapshot`s keyed by `determination_id. Each assembled + hashed + bound into that subject’s envelope in the compute+sign phase, carried to persist. Medicaid persists ALL subjects in ONE all-or-nothing tx (persist_determinations, ADR-035 Slice 2); caps/wic persist in the handler tx.

program_input (STRUCTURAL-VALUE)

CAPS {rules_input, eligibility_type, activity_*, child:{person_id,age,special_needs}, outputs}; WIC {rules_input, participant:{category,infant_age_months,breastfeeding,adjunctive_program}, nutritional_risk_documented}; TANF {rules_input incl. time_limit/sanction/PR inputs, benefit i/o, deprivation, time_limit_exceeded}; Medicaid {cmd_cascade_priority_order, per-COA magi/non_magi/chip/tma + denial-reason evals, hierarchy result + assigned_coa, countable_resources, booleans, member SOLQ}. Each via a {prog}_program_input(…​) helper.

policy_params (STRUCTURAL-VALUE)

build_{prog}_policy_params(params) — the resolved thresholds verbatim. The named §39 federal-parameter version stamp is a follow-up (resolved values + corpus_hash are the v1 guarantee).

corpus_hash

CAPS: rules evaluate() runs once before the child loop → capture once, reuse for every child. WIC: per participant → each captures its own. TANF: evaluate_eligibility always runs (the time-limit branch is after it; sanction/PR are its inputs) → always Some. Medicaid: capture from evaluate_cmd_cascade_priority (in resolve_priority_order, runs unconditionally) — NOT hierarchy (skipped on the no-COA path). All calls share one engine corpus.

Wrapper-client plumbing

TanfRulesClient::evaluate_eligibility_with_corpus_hash + MedicaidRulesClient::evaluate_cmd_cascade_priority_with_corpus_hash return (…, String); the existing methods keep their signatures and delegate (.0) → no call-site fan-out.

FTI chain entry (tanf/medicaid)

New canopy_common::fti_audit::insert_fti_chain_entry(&mut PgConnection, originating_system, &FtiAuditEntry) (the advisory-lock + chain-from + compute_fti_event_hash + INSERT … clock_timestamp() body on the caller’s connection; pool-based log_access becomes a thin wrapper). Called as the LAST step inside the existing persist tx, after all det+snapshot inserts, before commit — TANF one entry; Medicaid appends all N at the end of its single tx. Entry: purpose=*Eligibility, action=Write, resource_type="determination_snapshot", resource_id=determination_id, accessed_by, data_elements_accessed = fact-class NAMES only (ADR-014 §2; the snapshot content is already covered by the signed snapshot_hash + the append-only guard). No fti_audit_log schema change.

FTI accessed_by

determine() receives only the outbound service token; the inbound Claims live in the handler. Pass an explicit accessed_by: &str (from claims.actor()/service_id()) from the handler into the persist path.

Carrier for pre-signed snapshot

caps/wic/medicaid sign in the compute phase but persist later → add snapshot: DeterminationSnapshot + signing_kid: Option<String> to DetermineOutput so the persist step inserts exactly what was hashed+signed. TANF persists in-line in determine() (no carrier).

Storage + tx

TANF: refactor create_determination(pool)→(&mut PgConnection), wrap det+snapshot+chain in db.begin(). caps/wic/medicaid: add create_determination_snapshot(&mut conn,…) (+ chain for medicaid) in the existing tx. tanf keeps writing tanf_household_snapshots (additive).

Migrations (ADR-016 expand)

Per service, next free slot ≥ 20260622000000 (own dir): ALTER TABLE {prog}_determinations ADD COLUMN IF NOT EXISTS snapshot_hash TEXT; + CREATE TABLE determination_snapshots (…) + a statement-level append-only trigger cloned from the SNAP guard (GUC canopy.snapshot_maintenance; INSERT unguarded). FTI services add no new chain table.

Supersession

Additive (expand only). tanf_household_snapshots kept (INSERT-only, read-nowhere). Medicaid’s magi_household_snapshots exists but has no active write/read path — untouched. The contract phase (drop dead tables) is a follow-up.

Legacy marker

{Prog}DeterminationRead { #[serde(flatten)] determination, snapshot_status } + ::new() from snapshot_hash.is_some(), on GET + list (all four services expose both). Signed {Prog}Determination untouched (the snapshot_hash field is additive + skip_serializing_if).

Byte-stability

.rescale(2) money; truncate_to_micros DateTime; DOB → NaiveDate. serde_jcs sorts object keys, so json! field order in the builders is irrelevant to the hash. Assemble → canonical_hash() → bind → sign; never mutate between hash and sign. Extend the proptest arb_program to all six variants.

Other services + verifier + orchestrator

No change. Tolerance via skip_serializing_if; the orchestrator already tolerates snapshot_hash (T1-10). But ProgramResult has no snapshot_hash field and the orchestrator builds its response without one — the hash is bound in the verified envelope only, NOT surfaced/persisted by the orchestrator until #879. T2-4’s durable storage is program-service-side.

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)

  1. SharedSnapshotStatus in canopy-contracts-eligibility; arb_program → all six Program variants.

  2. CAPS (services/canopy-caps, crates/canopy-contracts-caps) — deps (serde_jcs/sha2/canopy-contracts-eligibility on the service; canopy-contracts-eligibility on the contract for SnapshotStatus); single pre-loop evaluate_with_corpus_hash reused 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; DetermineOutput carrier; handler-tx create_determination_snapshot; column + immutable table + trigger; CapsDeterminationRead marker on GET + list.

  3. WIC — same, per-participant; wic_program_input + build_wic_policy_params.

  4. Tests (mirror snap_test.rs, dedicated CANOPY_PORT_POSTGRES_{CAPS,WIC}_5432 DBs) — re-hash == signed hash; snapshot_status Present + legacy NoInputSnapshot; immutability (UPDATE/DELETE RAISE, GUC-gated succeeds); corpus_hash present (incl. on a denial); insta .snapshot_hash ⇒ "[HASH]" redaction.

  5. 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)

  1. canopy-common — extract insert_fti_chain_entry; log_access becomes its wrapper; chain test passes unchanged.

  2. TANF — deps; TanfRulesClient::evaluate_eligibility_with_corpus_hash (always Some); tanf_program_input + build_tanf_policy_params; facts incl. assets leaves (no person_id); accessed_by from handler claims; refactor create_determination&mut PgConnection (update all callers); persist det → snapshot → insert_fti_chain_entry(&mut tx, "canopy-tanf", …) (purpose TanfEligibility, action Write, data_elements = names) last before commit; keep create_household_snapshot; column + table + trigger; TanfDeterminationRead marker.

  3. Tests — MR1 set + an fti_audit_log determination_snapshot entry is emitted and verify_fti_chain still passes; corpus_hash Some on a time-limit denial.

  4. Docs — tanf api/`data-models/` ( chain-entry behavior); CHANGELOG; plan MR2 cells; regen tanf OpenAPI.

MR3 — Medicaid (per-member FTI), Closes #685

  1. Medicaid — deps; MedicaidRulesClient::evaluate_cmd_cascade_priority_with_corpus_hash (capture once in resolve_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); DetermineOutput carrier; accessed_by threaded to persist_determinations; in the single all-or-nothing tx create the application + N determinations + N snapshots, then append N insert_fti_chain_entry(&mut *tx, "canopy-medicaid", …) last before commit; column + table + trigger; MedicaidDeterminationRead marker.

  2. Tests — per-member re-hash == signed hash; one fti_audit_log entry per determination; chain verifies.

  3. 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 refreshcargo 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_entry signature. Landed as insert_fti_chain_entry(conn: &mut PgConnection, entry: &FtiAuditEntry) — the originating_system is read off entry.originating_system rather than passed separately (the plan sketched a 3-arg form). Pool-based log_access is now a thin begin → insert_fti_chain_entry → commit wrapper; the pre-existing fti_audit_hash_chain_test.rs passes unchanged.

  • program_input / policy_params for the FTI services built inline via serde_json::json!. TANF and Medicaid build these two STRUCTURAL-VALUE blobs with inline serde_json::json!(…​) at the assembly site rather than through a named {prog}_program_input(…​) helper. json!/from_value do not match the B3a serde_json::Value text-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_hash call inside resolve_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 generic evaluate_with_corpus_hash directly) so dispatch was left untouched, again to keep B3a flat.

  • Medicaid cohesion extractions. To stay within the LOCKED budgets, determine bundles the caller identity into CallerContext { bearer_token, accessed_by } (≤7 args), and the per-snapshot FTI entry is built by a small medicaid_snapshot_chain_entry(accessed_by, determination_id) helper so persist_determinations stays under the >100-LOC threshold. MemberFlags gained #[derive(serde::Serialize)] to be embeddable in program_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_snapshots is still written (read-nowhere); magi_household_snapshots has no active write/read path and was left untouched. Dropping the dead tables is the filed contract-phase follow-up.

  • Orchestrator. Unchanged. The snapshot_hash is bound into each service’s signed envelope (which the orchestrator already tolerates), but ProgramResult carries no snapshot_hash field, 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_hash column + the determination_snapshots blob), 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 + drop tanf_household_snapshots (+ magi_household_snapshots).

  • feat: provenance-enrichment of the tanf/medicaid orchestrator→program wire so their snapshot facts carry provenance like SNAP (currently None).

  • chore: consolidate SnapshotStatus — migrate SNAP’s canopy-contracts-snap enum to the shared canopy-contracts-eligibility one (optional).

  • (Already filed in T1-10: ProgramResult.snapshot_hash receipt #879; §39 policy-version stamp; §53 key-retention; §57 supersession; #878 dead-signer delete.)

Edit this page · default