T2-3 — Determination Snapshot: Cross-Program Input Capture (raw SOLQ) (#684)

On this page

Epic &56 / Track 2, T2-3 (#684) — ADR-028 cross-program capture. T1-10 (#678) + T2-4 (#685) gave every program service an immutable, signature-bound input snapshot. ADR-028 named cross-program inputs (EE15 assigned_coa, ELE, TMA, SOLQ/FDSH) as a Track-2 gap. Scope-reconciled against code reality, the one genuinely unfrozen input a determination consumes is the raw SSA SOLQ record (only the five derived ABD booleans were frozen). T2-3 freezes a by-value SOLQ projection into a new typed cross_program_inputs field. Single MR; closes #684.

Scope boundary

T2-3 is snapshot capture of the cross-program/external inputs a determination actually consumes today. Realized: raw SOLQ by value (canopy-medicaid). Already satisfied (no new capture): EE15 assigned_coa (the determination’s own output, in program_input); ELE (no snapshot — its linkage lives in ele_grant_events.source_determination_id/application_id); the TMA inputs (tanf_termination_date, had_tanf_in_prior_months, already in program_input). OUT of scope (filed follow-ups): the TMA upstream determination id by-reference (no TANF det-id reaches the Medicaid TMA flow — needs a tanf.case_closed event-contract change); FDSH capture (a stub — not consumed by determine(), so nothing to freeze until the gating feature exists); the per-fact derivation graph (T2-2 #679); provenance- enrichment of the tanf/medicaid wire (#884).

Status

Step Description Status

(plan)

This execution plan + nav entry.

Done (2026-06-21) — the T2-3 (plan) commit.

contract

canopy-contracts-eligibility — typed cross_program_inputs: Option<CrossProgramInputs> (+ SolqLeaf) on DeterminationSnapshot, custom skip_serializing_if for a single canonical "empty" encoding, conditional schema_version doc (verifier contract).

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

producers

The 4 non-medicaid producers + 2 proptest literals get cross_program_inputs: None; canopy-medicaid adds build_cross_program_inputs (rescale2 + sort by person_id), the conditional schema_version, and the conditional solq FTI data_elements.

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

tests

Proptest arb extension (None | Some(non-empty)) + a fixed-fixture byte-stability test (pinned golden hash) + a present-are-hashed test; canopy-medicaid integration tests (SOLQ → schema_version 2 + projection re-hashes; no-SOLQ → omitted + schema_version 1; FTI solq element present/absent).

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

docs

ADR-028 Amendment 1; data-models/+api/ medicaid pages; CHANGELOG; master-plan T2-3 row + detail paragraph; this plan’s cells + As-built. Close #684 + file the 2 follow-ups.

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

Context

The Medicaid ABD cascade consumes SSA SOLQ data: derive_abd_flags_from_solq projects a SolqRecord onto five Phase-D booleans (lost_ssi_due_to_cola, …). Before T2-3 only those derived booleans were frozen (in program_input.member_flags), so a verdict could not be replayed if the derivation logic changed. T2-3 freezes the source record by value alongside the derived flags — the same self-containment posture as the IEVS raw-reconstruction-plus-resolved-value pattern (snapshot.rs).

Decisions

Topic Decision

Shape

A typed cross_program_inputs field on the shared DeterminationSnapshot (not the untyped program_input blob) — auditable, queryable, zero new serde_json::Value. CrossProgramInputs { solq: Option<Vec<SolqLeaf>> }; SolqLeaf is a local by-value projection of SolqRecord + person_id (NOT a reuse of the wire DTO).

Canonical empty

A custom skip_serializing_if omits the field when None or empty, so None / Some(empty) / Some({solq:[]}) all serialize identically (omitted) — one hash for "no cross-program inputs." The producer only ever emits None for empty; the proptest arb generates only None | Some(non-empty).

schema_version

Conditional: 2 iff cross_program_inputs is present, else 1 (same decision drives both, so they never disagree). No-SOLQ snapshots stay 1, byte-identical to pre-T2-3. A re-verifier MUST honor the version — an unknown version is refused, not silently re-hashed (no deny_unknown_fields).

Vec ordering

Vec<SolqLeaf> sorted by person_id at assembly — JCS does not order array elements and the source HashMap iterates nondeterministically.

Subject vs household

Capture the whole-household SOLQ map (sorted), matching facts.household.members self-containment + forward-compatible with per-member ABD scoring (#858).

FTI audit

data_elements_accessed gains "solq" (field name only, ADR-014 §2) only when that determination’s snapshot froze SOLQ — threaded via a per-determination has_solq bool so a no-SOLQ determination does not over-report.

Migration

None — the field lives in the existing determination_snapshots.snapshot JSONB blob.

Snapshot types

DeterminationSnapshot.cross_program_inputs: Option<CrossProgramInputs> (additive, skip_serializing_if = cross_program_inputs_is_empty). CrossProgramInputs { solq: Option<Vec<SolqLeaf>> } with is_empty(). SolqLeaf { person_id, ssi_active, monthly_ssi_amount, lost_ssi_due_to_cola_flag, benefit_category, monthly_benefit_amount, disability_onset_date, lost_ssi_as_disabled_child_flag } (money rescale(2)-d; 4 optionals skip_serializing_if). See crates/canopy-contracts-eligibility/src/snapshot.rs.

Verification

cargo build --workspace --exclude canopy-portal; cargo test -p canopy-contracts-eligibility --test snapshot_roundtrip (roundtrip + the pinned byte-stability golden hash); cargo xtask quality-budgets (all LOCKED — typed, no new serde_json::Value); cargo xtask dev refresh then cargo nextest run -p canopy-medicaid (the SOLQ-freeze + no-SOLQ tests); cargo insta test shows ZERO new diffs (no-SOLQ snapshots are byte-stable — make_body passes ssa_solq: None); full cargo xtask validate + check-docs.

As-built notes

Built as a single MR closing #684, mirroring the T2-4 mechanics.

  • Custom skip predicate. cross_program_inputs_is_empty(&Option<CrossProgramInputs>) carries [expect(clippy::ref_option, …)] — serde’s skip_serializing_if dictates the &Option<T> signature (the repo precedent is [expect], not #[allow]).

  • Byte-stability proof. The vector test asserts the canonical bytes of a no-cross-program snapshot contain no cross_program_inputs key, and pins the golden canonical hash c6b7e983…db3e — so the additive field is provably invisible to every existing snapshot’s hash (no churn). Confirmed: cargo insta test shows zero diffs.

  • Quality budgets flat (B2 123, B3 src 757, B4 137, B5 309) — the typed types add no serde_json::Value; the producer logic lives in a helper so evaluate_member stays under the B2 ceiling.

  • Deferred (filed + /relate #684): TMA upstream-determination-id by-reference; FDSH consumption → snapshot capture.

Follow-ups

File each as a GitLab issue and /relate #684:

  • feat: TMA upstream-determination-id by-reference — thread the source TANF determination id through tanf.case_closed + tanf_tma_coverage + the orchestrator so the Medicaid TMA snapshot can record it.

  • feat: FDSH consumption → snapshot capture — once FDSH results gate a determination, freeze them like SOLQ (today store/fdsh.rs is a stub; determine() never reads it).

  • (Already filed) provenance-enrichment of the tanf/medicaid wire #884.

Edit this page · default