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
- Status
- Context
- Scope decision
- Decisions
- Implementation
- MR1 (a) contract —
canopy-contracts-eligibility - MR1 (b) signing —
canopy-signing - MR1 (c) rules-client —
canopy-rules-client - MR1 (d) tests + docs
- MR2 (a) snap deps + RichInputs
- MR2 (b) handler — Option E
- MR2 (c) determine() + corpus_hash
- MR2 (d) assembly
- MR2 (e) storage + tx
- MR2 (f) IEVS join
- MR2 (g)
no_input_snapshotmarker - MR2 (h) tests + docs (FINAL T1-10 MR)
- MR1 (a) contract —
- Snapshot types (
crates/canopy-contracts-eligibility/src/snapshot.rs) - Verification
- As-built notes
- Follow-ups
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) — |
MR1 (a) contract |
|
Done (2026-06-20) — |
MR1 (b) signing |
|
Done (2026-06-20) — |
MR1 (c) rules-client |
|
Done (2026-06-20) — |
MR1 (d) tests + docs |
Proptest + signing tolerance + |
Done (2026-06-20) — the MR1 docs commit. |
MR2 (a-b) deps + handler |
|
Done (2026-06-20) — |
MR2 (c-d) determine + assembly |
|
Done (2026-06-20) — |
MR2 (e) storage + tx |
|
Done (2026-06-20) — |
MR2 (f-g) IEVS + marker |
IEVS join ( |
Done (2026-06-20) — |
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); |
Done (2026-06-20) — |
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 |
|
Provenance/identity capture |
Keep the lean math |
|
Snapshot |
Fact-version identity |
Each leaf carries |
Evaluated inputs |
|
|
|
|
|
|
|
Durable signed-hash storage |
|
Binding/sign order |
Assemble → |
Storage |
|
Re-verify |
Re-hash: JSONB → typed |
Transaction |
Wrap only the det-insert + snapshot-insert in |
Byte-stability |
|
Other 4 programs + verifier + orchestrator |
No change. Tolerance via |
|
Read wrapper |
Legacy signer (§50) |
The "legacy |
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.members → MemberLeaf; .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_hashfield isOption<String>
skip_serializing_if, defaultedNoneinSignableDetermination::build(), so the verifier and the four programs not yet emitting a snapshot are byte-unchanged automatically (the ADR-035person_idprecedent). 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 usedserde_jcs(#387); the orphanedDetermination/Ecdsa*Signerincanopy-eligibility/src/determination.rshas 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
ApplicationContextdrops them (leanIncomeRecord), so the handler re-parses the SAME body bytes into tolerantRichInputs(provenance + fact-id
as_ofcaptured; a direct/lean caller still parses). The math is unchanged. -
IEVS figure stays snap-local (ADR-004 §2025(e)).
provenance.proposed_valueisNonein persons (T1-9); the snapshot joins the figure fromievs_discrepanciesbyprovenance.origin = ievs:{id}. The T1-10 snapshot is where persons + the discrepancy meet for a self-contained leaf. -
Durable + re-verifiable.
snapshot_hashis onsnap_determinations(so the signed envelope reconstructs from its row; its absence is theno_input_snapshotmarker); thedetermination_snapshotsblob 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 carrySTRUCTURAL-VALUEmarkers;jws_kid+RichInputsare 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 orchestratorProgramResultreceipt) 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 orphanedDetermination
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 embedssigning_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): aGET /determinations/{id}/snapshotQC-debug read.