T1-10 — SNAP Determination Input Snapshot (flat + corpus-hash, program-signed) (#678)

On this page

Epic &56 / Track 1, T1-10 (#678) — ADR-028 v1. At verdict time canopy-snap freezes the inputs it evaluated into an immutable, signature-bound flat snapshot: proven facts with provenance, the resolved policy parameters, the exact evaluated input, and the ruleset corpus content-hash. The snapshot’s SHA-256 (over RFC 8785 canonical bytes) is a signed snapshot_hash field on the determination, durably stored so the signature is re-verifiable from storage. The orchestrator receives outcome + hash only (ADR-002 black-box preserved). Legacy determinations are marked no_input_snapshot. Shipped in two MRs under #678. This is the last open Track-1 unit — closing #678 completes epic &56 Track 1.

Scope boundary

T1-10 is snapshot capture + binding + storage ONLY for SNAP. The consumers — appeals replay (§68 / T2-8), overpayment recalc (T2-7), QC reproduction, cross-program capture (T2-3/T2-4), the derivation graph (T2-2), supersession-chain walking (§57), and the orchestrator hash-receipt on ProgramResult (deferred to avoid a wide constructor fan-out) — are Track 2 / follow-ups, out of T1-10 scope. After T1-10 a SNAP determination has a reproducible, signature-bound, durably-stored input record; nothing yet reads it for adjudication. The other four program services emit no snapshot until T2-4, so their determinations are (correctly) no_input_snapshot.

Status

Step Description Status

(plan)

This execution plan + nav entry.

Done (2026-06-20) — 75d3925.

MR1 (a) contract

canopy-contracts-eligibility — new snapshot.rs: DeterminationSnapshot + typed fact leaves (each fact_id/person_id/provenance) + IevsReconstruction; program_input + policy_params (STRUCTURAL-VALUE) + corpus_hash + as_of + schema_version; canonical_hash() (hex SHA-256 over serde_jcs::to_vec). Mandatory proptest roundtrip + re-canonicalize byte-stability.

Done (2026-06-20) — e4d116a5.

MR1 (b) signing

canopy-signingsnapshot_hash: Option<String> (skip_serializing_if, default None in build(), NOT a param) on SignableDetermination; new jws_kid(jws) → Option<String> helper; tolerance tests cloned from the person_id precedent.

Done (2026-06-20) — 19ec985.

MR1 (c) rules-client

canopy-rules-client#[serde(default)] corpus_hash: String on EvaluateResponse + a sibling evaluate_with_corpus_hash(); evaluate() delegates (other callers untouched).

Done (2026-06-20) — 8d16a22.

MR1 (d) tests + docs

Proptest + signing tolerance + jws_kid + canonical_hash + client units; CHANGELOG; this plan’s MR1 cells; master-plan T1-10 row → In progress.

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

MR2 (a-b) deps + handler

canopy-snap adds the three contracts deps + serde_jcs/sha2; new rich_inputs.rs (tolerant typed input structs that parse BOTH lean + rich bodies); handler reads Bytes + parses twice (ApplicationContext for math, RichInputs for snapshot).

Done (2026-06-20) — d4b55b9d.

MR2 (c-d) determine + assembly

determine() gains rich: &RichInputs; corpus_hash threaded from whichever evaluate ran (alien short-circuit or main); assemble DeterminationSnapshot (rescale/truncate every Decimal/DateTime) → canonical_hash()envelope.snapshot_hash = Some(hash) before signing → jws_kid.

Done (2026-06-20) — d4b55b9d.

MR2 (e) storage + tx

snapshot_hash on SnapDetermination + a snap_determinations.snapshot_hash column; new immutable determination_snapshots table + append-only trigger; create_snap_determination&mut PgConnection + new create_determination_snapshot; det+snapshot persisted in one tx (create_snap_application stays on the pool).

Done (2026-06-20) — d4b55b9d.

MR2 (f-g) IEVS + marker

IEVS join (origin = ievs:{id}get_discrepancyIevsReconstruction; figure is snap-local, NOT proposed_value); no_input_snapshot read marker (snapshot_hash.is_some()) on GET + list.

Done (2026-06-20) — d4b55b9d.

MR2 (h) tests + docs (FINAL T1-10 MR)

Integration (re-hash == signed hash; reproducibility; immutability; provenance+identity; lean-body tolerance; IEVS join; corpus_hash both paths; legacy marker; as_of; regression); api/canopy-snap.adoc + data-models/canopy-snap.adoc; CHANGELOG; ADR-028 status note; this plan → Done; master-plan T1-10 row → Done; close #678 + follow-ups.

Done (2026-06-20) — d4b55b9d.

Context

snap_determinations (snap migration 20260326000000) stores 15 verdict/output columns + the JWS signature and no income/asset/expense/household facts; the orchestrator’s ApplicationContext is assembled fresh from canopy-persons reads and then discarded. Once facts are valid-time-versioned + correctable (ADR-027), "what did this determination see?" is unanswerable from the live store — breaking appeals (adjudicate on the facts as they stood) and QC / Pub 1075 (reproducible inputs). ADR-028 v1 fixes this: the program service freezes the inputs into an immutable, signature-bound snapshot, and the orchestrator receives outcome + a snapshot hash only.

The inputs canopy-snap evaluates (services/canopy-snap/src/determine.rs determine()) are: the proven facts (income/assets/expenses + household composition, each with provenance + a correction-stable fact id — forwarded by the orchestrator inside the ApplicationContext income/asset/expense serde_json::Value arrays, but silently truncated when snap deserializes them into its lossy lean IncomeRecord); the resolved policy parameters (build_snap_eligibility_thresholds); the exact aggregated rules_input sent to the ruleset, plus categorical_eligibility_type/utility_tier, the alien-eligibility inputs/result, and the self-employment deduction; and the ruleset corpus content-hash (T1-1 #682, surfaced by canopy-rules per evaluate but dropped today at canopy-rules-client).

Scope decision

The snapshot type lives in crates/canopy-contracts-eligibility (shared, program-agnostic for the T2-4 fan-out). Provenance + fact identity are recovered by re-parsing the raw request body into tolerant typed input structs (the lean math ApplicationContext is unchanged) — so a direct/test SNAP caller sending the lean shape still works, and an orchestrator body carrying the rich persons facts captures provenance. The IEVS "said X → verified Y" reconstruction is a snap-local join to ievs_discrepancies keyed on provenance.origin = "ievs:{id}" — the raw figure stayed snap-local per T1-9 (ADR-004 §2025(e)); persons carries origin + source=ievs but proposed_value is None.

Decisions

Decision Resolution

Snapshot type home

crates/canopy-contracts-eligibility/src/snapshot.rs (shared / program-agnostic).

Provenance/identity capture

Keep the lean math ApplicationContext untouched. The handler reads Bytes and from_slice twice on the same bytes: ApplicationContext (math) + RichInputs (snapshot). RichInputs uses tolerant typed input structs — required {type, amount/value, frequency} (present in both shapes), optional #[serde(default)] id/person_id/effective_date/provenance (rich shape only). Parses both, 0 added to B3a, math unchanged. Both from_slice failures map to ApiError::BadRequest (preserve the old Json<…> 400).

as_of

Snapshot as_of is a definite NaiveDate = rich.as_of.unwrap_or_else(canopy_common::clock::today) (gated-clock-aware). The snapshot records exactly the date used.

Fact-version identity

Each leaf carries fact_id (= persons id, correction-stable) + person_id; with as_of + values this is the v1 identity (ADR-028 §38). Exact version_id is not on the persons wire → Track-2 graph (named).

Evaluated inputs

program_input: serde_json::Value (STRUCTURAL-VALUE) = the exact rules_input (or null if alien-short-circuited) + categorical_eligibility_type + utility_tier + alien_eligibility_inputs + the alien reason/citation + the SE summary. The typed facts are the raw provenanced inputs; program_input is what the ruleset actually evaluated (reproducibility).

policy_params

serde_json::Value (STRUCTURAL-VALUE) = build_snap_eligibility_thresholds(params) verbatim. The named §39 federal-parameter-table version stamp is a follow-up (the resolved values + corpus_hash are the v1 reproducibility guarantee).

corpus_hash

Option<String>, captured from whichever evaluate produced the operative verdict — the alien evaluate on short-circuit, else the main evaluate (same engine corpus). Some in every real flow; None only defensive. alien_eligibility::evaluate bubbles up its corpus_hash.

signing_kid

Option<String> via canopy_signing::jws_kid() (canopy-signing owns the JWS format + base64ct); parsed post-sign, any failure → None + warn; stored on the determination_snapshots row (atomic, inside the tx), NOT in the hashed snapshot (known only post-sign).

Durable signed-hash storage

snapshot_hash: Option<String> on SnapDetermination (models.rs) + a snap_determinations.snapshot_hash TEXT column → the signed envelope is reconstructable/re-verifiable from its own row, and snapshot_hash IS NULL IS the no_input_snapshot marker (no join).

Binding/sign order

Assemble → canonical_hash()envelope.snapshot_hash = Some(hash) before canonical_signing_payload() → sign → jws_kid → persist (det row incl. hash + snapshot blob incl. kid) in one tx.

Storage

snap_determinations gains snapshot_hash TEXT NULL. New immutable determination_snapshots: determination_id PK/FK→snap_determinations(id), snapshot JSONB, corpus_hash TEXT NULL, as_of DATE, signing_kid TEXT NULL, created_at TIMESTAMPTZ (bound explicitly). schema_version lives in the JSONB blob (no INT column → no sqlx u32/INT mismatch). Append-only DB trigger.

Re-verify

Re-hash: JSONB → typed DeterminationSnapshotserde_jcs → SHA-256, compared to snap_determinations.snapshot_hash. Never re-hash raw JSONB (key reorder). No canonical-TEXT copy.

Transaction

Wrap only the det-insert + snapshot-insert in db.begin()&mut *tx (create_snap_determination&mut sqlx::PgConnection; new create_determination_snapshot). create_snap_application stays on the pool (it precedes the rules HTTP calls — never hold a tx across the network).

Byte-stability

.rescale(2) every money Decimal (fact amounts/values + IEVS figures); truncate_to_micros every DateTime<Utc> (incl. provenance.recorded_at); parse member DOB to NaiveDate; program_input/policy_params floats are serde_jcs-deterministic; proposed_value is producer-restricted to a JCS-stable subset (None for IEVS facts anyway).

Other 4 programs + verifier + orchestrator

No change. Tolerance via skip_serializing_if; the orchestrator already forwards provenance-bearing facts; the hash crosses back in the verified envelope. The ProgramResult receipt is a follow-up.

no_input_snapshot (§58)

Read wrapper SnapDeterminationRead { #[serde(flatten)] determination, snapshot_status }, enum SnapshotStatus { Present, NoInputSnapshot } (rename_all="snake_case"), derived from snapshot_hash.is_some(). Applied to GET + list. Signed SnapDetermination untouched.

Legacy signer (§50)

The "legacy serde_json::to_vec signer" is the orphaned EcdsaDeterminationSigner + Determination in services/canopy-eligibility/src/determination.rs (zero callers since #387). Deleting it is a follow-up, not T1-10 (the live path already uses serde_jcs).

Implementation

Two MRs under #678 (MR1 Relates to, MR2 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 (a) contract — canopy-contracts-eligibility

New src/snapshot.rs (SPDX): the types in Snapshot types (crates/canopy-contracts-eligibility/src/snapshot.rs). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)], no deny_unknown_fields (forward-compat; Option fields skip_serializing_if). impl DeterminationSnapshot { pub fn canonical_hash(&self) → Result<String, serde_json::Error> } = hex SHA-256 ({b:02x}) over serde_jcs::to_vec(self), shared by snap + the future re-verifier. New deps: canopy-contracts-facts (for Provenance), serde_jcs, sha2. pub mod snapshot;.

MR1 (b) signing — canopy-signing

Add #[serde(skip_serializing_if = "Option::is_none")] pub snapshot_hash: Option<String> to SignableDetermination (default None in build(), NOT a param; doc-comment mirrors the person_id block). Add pub fn jws_kid(jws: &str) → Option<String> (split on ., take the header segment, base64url-decode via Base64UrlUnpadded, serde_json::from_slice, .get("kid").and_then(Value::as_str) .map(String::from); None on any failure).

MR1 (c) rules-client — canopy-rules-client

#[serde(default)] pub corpus_hash: String on EvaluateResponse; new evaluate_with_corpus_hash() returning (serde_json::Value, String); evaluate() delegates (.0) — its many callers untouched.

MR1 (d) tests + docs

Mandatory proptest roundtrip for DeterminationSnapshot (crates/canopy-contracts-eligibility/tests/): serde byte-stable AND typed→serde_jcs→parse→serde_jcs byte-identical (the re-verify invariant); generators cover Decimal scale-0/2 + extremes, float program_input/policy_params, nested proposed_value, Option None/Some variance. Signing tolerance tests cloned from envelope.rs person_id precedent for snapshot_hash. jws_kid unit (round-trips a real detached JWS header; None on garbage). canonical_hash determinism. Client: missing corpus_hash""; present captured; evaluate() unchanged. CHANGELOG.adoc == Unreleased; this plan’s MR1 cells → Done; master-plan T1-10 row → In progress.

MR2 (a) snap deps + RichInputs

services/canopy-snap/Cargo.toml: add canopy-contracts-persons, canopy-contracts-facts, canopy-contracts-eligibility, serde_jcs, sha2. New services/canopy-snap/src/rich_inputs.rs (mod rich_inputs;): RichInputs { #[serde(default)] income: Vec<IncomeInput>, assets: Vec<AssetInput>, expenses: Vec<ExpenseInput>, members: Vec<MemberInput>, as_of: Option<NaiveDate> } + the four tolerant input structs (required type/amount/value/frequency; optional id/person_id/effective_date/provenance; MemberInput mirrors the wire member shape).

MR2 (b) handler — Option E

determine_handler.rs post_determine: change Json(context): Json<ApplicationContext> to body: axum::body::Bytes; from_slice::<ApplicationContext> (math) AND from_slice::<RichInputs> (snapshot) on the same bytes, both mapping failure to ApiError::BadRequest; pass &rich to determine(). Keep request_body = ApplicationContext in #[utoipa::path].

MR2 (c) determine() + corpus_hash

determine() gains rich: &RichInputs (update the caller). Thread corpus_hash: Option<String>: alien_eligibility::evaluate returns its corpus_hash (via evaluate_with_corpus_hash); the main path uses evaluate_with_corpus_hash. Set corpus_hash from the alien evaluate on short-circuit, else the main evaluate.

MR2 (d) assembly

After build-envelope: assemble DeterminationSnapshot from rich (typed leaves
fact_id/person_id/provenance) + program_input (the rules_input + alien inputs/result + categorical_eligibility_type + utility_tier + the SE summary)
policy_params (build_snap_eligibility_thresholds) + corpus_hash + resolved as_of
RichInputs.membersMemberLeaf; .rescale(2)/truncate_to_micros every Decimal/DateTime; schema_version = 1. canonical_hash()?envelope.snapshot_hash = Some(hash) (before canonical_signing_payload()) → sign → signing_kid = canopy_signing::jws_kid(&envelope.signature).

MR2 (e) storage + tx

models.rs: add snapshot_hash: Option<String> to SnapDetermination (+ its construction from envelope.snapshot_hash + the row mirror + test constructors). store/mod.rs: refactor create_snap_determination to &mut sqlx::PgConnection (update callers); add create_determination_snapshot(&mut PgConnection, snapshot, corpus_hash, as_of, signing_kid). In determine() wrap only these two in db.begin()&mut tx → commit. Migration 20260621000000_.sql (SPDX, ADR-016 expand): (1) ALTER TABLE snap_determinations ADD COLUMN IF NOT EXISTS snapshot_hash TEXT; (2) CREATE TABLE determination_snapshots (…) + a statement-level append-only trigger cloned from services/canopy-security/migrations/20260603120000_audit_events_append_only_guard.sql (GUC canopy.snapshot_maintenance; INSERT unguarded).

MR2 (f) IEVS join

For each income leaf with provenance.origin = Some(o) where o.strip_prefix("ievs:") parses as IevsDiscrepancyId, get_discrepancy(id) (pre-tx pool read) → populate IevsReconstruction (.rescale(2) the figures). None/unparseable/not-found → ievs: None (+ warn on a malformed-but-prefixed origin). proposed_value is None for these facts (T1-9) — the figures come from the join, not provenance.

MR2 (g) no_input_snapshot marker

New SnapDeterminationRead wrapper returned by get_determination + the list endpoint; snapshot_status derived from snapshot_hash.is_some(). The signed SnapDetermination is untouched.

MR2 (h) tests + docs (FINAL T1-10 MR)

Integration (devstack, mirror services/canopy-snap/tests/snap_test.rs): re-hash == signed hash (read snapshot blob → typed-parse → canonical_hash() == snap_determinations.snapshot_hash); reproducibility (re-run the ruleset with the snapshot’s program_input.rules_input + corpus → same verdict); immutable (raw UPDATE/DELETE RAISE; GUC-gated UPDATE succeeds); provenance + identity (rich body → leaf carries fact_id/person_id/source/author/status/origin/recorded_at); lean-body tolerance (a direct IncomeRecord body → snapshot still assembles, provenance None); IEVS joined (seed ievs_discrepancies, income origin=ievs:{id}IevsReconstruction populated; proposed_value None); corpus_hash present for BOTH a normal determine AND an alien short-circuit denial; legacy no_input_snapshot (a row with NULL snapshot_hash → GET/list → marker); as_of (with/without on the wire); regression (an existing program-signature verify test still passes — other programs byte-unchanged); update the insta wire snapshot (redact .snapshot_hash). Docs: api/canopy-snap.adoc (the snapshot_hash field + determination_snapshots + the read marker), data-models/canopy-snap.adoc (the new table + column), CHANGELOG.adoc, an ADR-028 status note reconciling §50; this plan Status → Done + As-built; master-plan T1-10 row → Done (YYYY-MM-DD) — <sha> + reconcile its Design note. No .claude/CLAUDE.md change (Track 2 remains). Close #678 + the mandatory closing comment.

Snapshot types (crates/canopy-contracts-eligibility/src/snapshot.rs)

DeterminationSnapshot { schema_version: u32 /*=1, in the JSONB blob, NOT a column*/,
  determination_id: DeterminationId, program: Program /*=Snap*/, as_of: NaiveDate,
  household_id: HouseholdId, application_id: ApplicationId, facts: SnapshotFacts,
  program_input: serde_json::Value /*STRUCTURAL-VALUE: rules_input + categorical
    + utility_tier + alien inputs/result + se summary*/,
  policy_params: serde_json::Value /*STRUCTURAL-VALUE: resolved thresholds*/,
  corpus_hash: Option<String> }   // snapshot_hash + signing_kid live on the DB rows
SnapshotFacts { income: Vec<IncomeFactLeaf>, assets: Vec<AssetFactLeaf>,
  expenses: Vec<ExpenseFactLeaf>, household: HouseholdComposition }
IncomeFactLeaf  { fact_id: Option<String>, person_id: Option<String>, income_type: String,
  amount: Decimal, frequency: String, provenance: Option<Provenance>, ievs: Option<IevsReconstruction> }
AssetFactLeaf   { fact_id: Option<String>, person_id: Option<String>, asset_type: String,
  value: Decimal, provenance: Option<Provenance> }
ExpenseFactLeaf { fact_id: Option<String>, person_id: Option<String>, expense_type: String,
  amount: Decimal, frequency: String, provenance: Option<Provenance> }
HouseholdComposition { household_size: u32, has_elderly_disabled_member: bool, members: Vec<MemberLeaf> }
MemberLeaf { person_id: String, relationship: String, age: Option<u32>,
  disability_status: Option<String>, date_of_birth: Option<NaiveDate> }
IevsReconstruction { discrepancy_id: IevsDiscrepancyId, self_reported_monthly_income: Option<Decimal>,
  verified_monthly_income: Option<Decimal>, variance_monthly: Option<Decimal> }
// Provenance reused verbatim from canopy_contracts_facts (proposed_value None for IEVS facts).

Verification

Per commit: cargo build + cargo clippy -p <crate> --all-targets — -D warnings
targeted nextest. After snap work: cargo xtask dev refresh then cargo nextest run -p canopy-snap -p canopy-contracts-eligibility -p canopy-signing -p canopy-rules-client. Before push: full cargo xtask validate + cargo xtask api-docs --update + cargo xtask quality-budgets (B3a may rise by the two STRUCTURAL-VALUE fields; a lock bump is surfaced-and-decided per ADR-030, never a silent --write-lock). Load-bearing assertions: stored snapshot re-hashes to the durably-stored signed snap_determinations.snapshot_hash; program_input re-runs the ruleset to the same verdict; row immutable (UPDATE/DELETE rejected, GUC allowed); provenance + fact_id
IEVS figures captured; lean bodies tolerated; corpus_hash present on both the normal AND alien-short-circuit paths; the other 4 programs + verifier byte-unchanged; legacy → no_input_snapshot.

As-built notes

  • Shipped in two MRs: MR1 (!648, merged 0970548f) — the additive contract
    signing + corpus-hash foundation (no behavior change). MR2 (d4b55b9d) — the canopy-snap assembly + immutable storage + the read marker + integration tests.

  • No 5-service change. The snapshot_hash field is Option<String>
    skip_serializing_if, defaulted None in SignableDetermination::build(), so the verifier and the four programs not yet emitting a snapshot are byte-unchanged automatically (the ADR-035 person_id precedent). Only canopy-signing (the field + jws_kid) and canopy-snap (populates it) changed.

  • No live legacy signer to retire (ADR-028 §50). canonical_signing_payload() already used serde_jcs (#387); the orphaned Determination/Ecdsa*Signer in canopy-eligibility/src/determination.rs has zero callers and is deleted in a follow-up, not here.

  • Context not enriched. The orchestrator already forwards the full provenance-bearing persons facts inside the income/asset/expense arrays; the snap math ApplicationContext drops them (lean IncomeRecord), so the handler re-parses the SAME body bytes into tolerant RichInputs (provenance + fact-id
    as_of captured; a direct/lean caller still parses). The math is unchanged.

  • IEVS figure stays snap-local (ADR-004 §2025(e)). provenance.proposed_value is None in persons (T1-9); the snapshot joins the figure from ievs_discrepancies by provenance.origin = ievs:{id}. The T1-10 snapshot is where persons + the discrepancy meet for a self-contained leaf.

  • Durable + re-verifiable. snapshot_hash is on snap_determinations (so the signed envelope reconstructs from its row; its absence is the no_input_snapshot marker); the determination_snapshots blob re-hashes (typed → serde_jcs → SHA-256) to it — verified end-to-end by the integration test.

  • Budgets: B3a/B3b held at their locked floors — the structural ruleset-I/O
    snapshot-test Values carry STRUCTURAL-VALUE markers; jws_kid + RichInputs are fully typed. No lock raise.

  • Bounded limitation (documented, not buried): T1-10 is capture + binding
    storage ONLY. Consumption (appeals replay, overpayment recalc, QC, the §39 policy-version stamp, the §53 retention window, supersession, the orchestrator ProgramResult receipt) is Track 2 / filed follow-ups.

Follow-ups

File each as a separate GitLab issue and /relate #678 — not implemented in these MRs:

  • feat: (orchestrator hash-receipt): ProgramResult.snapshot_hash (+ update all constructors) so the orchestrator response surfaces the per-program snapshot hash (deferred to avoid the constructor fan-out; the hash already crosses back in the verified envelope + is stored snap-side).

  • chore: (eligibility cleanup): delete the orphaned Determination
    Ecdsa*Signer/Verifier (services/canopy-eligibility/src/determination.rs, zero callers) — the ADR-028 §50 dead signer.

  • feat: (ADR-028 §39 policy version): an explicit jurisdiction/federal-parameter-table version stamp in the snapshot (v1 relies on the resolved-values content + corpus_hash).

  • feat: (ADR-028 §57 supersession): previous_determination_id + effective-period
    the materiality/overpayment chain walk (Track 2).

  • feat:/chore: (ADR-028 §53 key-retention window): retain verifying keys beyond the JWKS rotation window for the appeal/QC horizon (the snapshot already embeds signing_kid).

  • feat: (ADR-028 §68 appeals/overpayment in-boundary read): the hearing-scoped program-service snapshot read for FTI programs + the ADR-014 chain extension (Track 2).

  • chore: (optional): a GET /determinations/{id}/snapshot QC-debug read.

Edit this page · default