Plan: Worker Fact Authoring and Provenance
On this page
ADRs ADR-027 and ADR-028 are Accepted (2026-06-02). The architectural decisions are recorded in Decisions (ratified direction) — including the sequencing decision (Option D: ship the demo decoupled, split the epic into two tracks). Per Option D the demo ships first; the epic tracks proceed on their own timeline. Track 1 is complete: T1-1 through T1-10 are Done (T1-4 sliced + T1-5 scoped + T1-8/T1-9/T1-10 each sliced into two MRs + scope-reconciled — T1-9 realized ADR-004-clean: the raw IEVS figure stays snap-local; T1-10 froze every SNAP determination’s inputs into an immutable, signature-bound snapshot; see their as-built notes in the Design section below). Track 2 (post-UAT correctness) is complete — T2-4 (#685, program-snapshot fan-out + FTI-chain join), T2-3 (#684, cross-program SOLQ capture), T2-5 (#686, audit chain-hash tamper-evidence), and T2-1 (#683, address + household_member valid-time versioning + determination supersession) are Done; T2-2 (#679, the self-explaining derivation graph across all five program services) is Done; T2-6 (#687, crypto-shred redaction + JWS key retention) is Done; T2-7 (#680, reported-change → dry-run materiality → recert nudge + change-of-circumstance notices) is Done (2026-06-26); T2-8 (#681, in-boundary overpayment recompute-from-snapshot + hearing-view + OverpaymentNotice) is Done (2026-06-27). Both tracks are delivered — epic &56 is complete.
Status
| MR | Description | Status |
|---|---|---|
Track 1 — SNAP-UAT-minimum |
||
T1-1 (#682) |
canopy-rules: surface a ruleset corpus content-hash per |
Done (2026-06-04) — SHA-256 over the name-sorted effective corpus on every |
T1-2 (#670) |
|
Done (2026-06-04) — reuses |
T1-3 (#671) |
canopy-persons expand: versioned tables (income/assets/expenses) + one-time backfill v1 + non-overlap constraint + seed-path emit. (Authored dual-write re-scoped to T1-4 — see Design note.) |
Done (2026-06-04) — migration + idempotent backfill fn + btree_gist EXCLUDE + seed/demo emit; 7 DB-level tests. |
T1-4 (#672) |
canopy-persons repoint: assets/expenses PUT/DELETE parity + authored versioned write (versions carry real |
Done (2026-06-18) — sliced (see Design as-built note). Slice 1 (assets/expenses PUT/DELETE parity) Done (2026-06-17); Slice 2 (authored versioned write + correction algorithm) Done (2026-06-18); Slice 3 (the sole-fact-store cutover — read-flip + writer cutover + legacy removal) Done (2026-06-18, closes #672). |
T1-5 (#673) |
canopy-persons: attributed fact events (payload attribution, outbox, batched finalize) + CLI parity. |
Done (2026-06-19) — |
T1-6 (#674) |
canopy-security: scoped change-history endpoint (attributable, not-yet-tamper-evident). (The Proposed-claim inbox feed was re-sliced to T1-9 #677 — it needs the IEVS |
Done (2026-06-19) — person-scoped |
T1-7 (#675) |
canopy-applications: finalize authors claims + assets/expenses + ADR-026 event hygiene. |
Done (2026-06-19) — finalize authors applicant self-reported income/assets/expenses into the version corpus ( |
T1-8 (#676) |
canopy-web: worker fact-authoring UI (SNAP) + #632 gate. Shipped in two MRs: asset/expense editors + the #632 write-gate sweep across all fact actions (!644), and the persons/member editor. (Scope reconciled: policy-aware |
Done (2026-06-19) — asset/expense + member editors authoring into the canopy-persons corpus / via the un-versioned identity endpoints; every fact-write #632-gated (real 403). (#676) |
T1-9 (#677) |
IEVS resolution → worker accept/reject → verified write-back to canopy-persons. Realized ADR-004-clean (the raw IEVS figure stays snap-local; only the worker-verified value enters the shared store, tagged |
Done (2026-06-20) — MR1 accept/reject write-back (!646, |
T1-10 (#678) |
SNAP determination input snapshot (flat + provenance + policy params + corpus-hash), program-service-signed + as-of assembly + legacy marker. |
Done (2026-06-20) — !648 ( |
Track 2 — post-UAT correctness |
||
T2-1 (#683) |
canopy-persons: household_members + addresses versioning + determination supersession ( |
Done (2026-06-22) — A1 (addresses) + A2 (household_members) + Half B (determination supersession substrate + the §57 snapshot-read endpoint) all landed; #683 closed. The production supersession trigger (orchestrator resolves the antecedent on a recert) is T2-7 (#680) by design. |
T2-2 (#679) |
Snapshot v2: derivation-edge graph + per-rule versioning (the self-explaining fact graph). |
Done (2026-06-23) — all five program services (SNAP/TANF/Medicaid/CAPS/WIC) freeze a typed |
T2-3 (#684) |
Snapshot: cross-program input capture — raw SOLQ frozen by value (EE15/ELE/TMA already captured; TMA-upstream-id + FDSH deferred). |
Done (2026-06-21) — raw SOLQ projection frozen in |
T2-4 (#685) |
FTI-bearing snapshots (tanf/medicaid) + program fan-out (tanf/medicaid/caps/wic) + join the ADR-014 chain. |
Done (2026-06-21) — all five program services (snap/tanf/medicaid/caps/wic) now capture ADR-028 input snapshots, and the two FTI-bearing ones (tanf, medicaid) join the ADR-014 hash chain. MR1 (caps + wic, non-FTI) + MR2 (tanf FTI) + MR3 (medicaid FTI). See the T2-4 plan. |
T2-5 (#686) |
Audit chain-hash hardening: extend the ADR-014 hash to cover actor + before/after content-hash (ADR-014 amendment). |
Done (2026-06-21) — v2 |
T2-6 (#687) |
Record redaction/expungement via crypto-shredding + JWS signing-key retention. |
Done (2026-06-25) — #687 closed; per-fact crypto-shred redaction/expungement (DEK destruction) + the JWS signing-key retention window. See the T2-6 plan. |
T2-7 (#680) |
Reported-change → dry-run materiality → recert nudge (renewals→eligibility wiring) + change-of-circumstance notices. |
Done (2026-06-26) — #680 closed; 6 dependency-sliced MRs (!690–!695): MR1 plan+ADR-027/028 amendments+ |
T2-8 (#681) |
Appeals snapshot-replay (in-boundary) + overpayment recalc-from-snapshot (in program services) + overpayment notices. |
Done (2026-06-27) — #681 closed; 6 dependency-sliced MRs (MR4–5 merged together). In-boundary SNAP overpayment recompute-from-snapshot ( |
Context
A data-flow trace during the Plan 4 demo build (#654) proved that the worker portal cannot author the facts a determination reads. The orchestrator builds its determination context only from canopy-persons (services/canopy-eligibility/src/orchestrator.rs:82-219), but the write surface is nearly empty: the applicant finalize path (services/canopy-applications/src/api/mod.rs:569-787) is the only creator of persons/household/members/income (and cannot write assets/expenses — PersonsClient has only the four write methods plus the read get_person, persons_client.rs:54-145); the worker income editor (services/canopy-web/src/api/income.rs:64-208) is the only worker write; intake "sections" (store/sections.rs:166-187) are read only for rendering; IEVS resolve (services/canopy-snap/src/store/verification.rs:184-205) flips a status only. Determinations also don’t snapshot inputs (snap_determinations is verdict+signature only; the ApplicationContext is discarded). The model that fixes this is ADR-027 (fact authoring + valid-time versioning) and ADR-028 (determination input snapshot).
Demo (decoupled). The worker SNAP→TANF→ELE demo walk is already demo-gated (CANOPY_E2E_SEED_PROFILE==='demo') and excluded from the default UAT suite, so it is independent of this epic. Per Decisions (ratified direction) Option D it ships now as an honest seeded/applicant-authored walk (a worker reviews an already-populated case and runs a real determination → real NOA → real ELE → real queue lifecycle), without staging a "worker typed these facts" beat. That demo is tracked separately (the re-scope of the paused Plan 4 MR11), not by this plan.
Decisions (ratified direction)
-
Sequencing — Option D. Ship the demo decoupled (above); split the epic into Track 1 — SNAP-UAT-minimum (a worker authors SNAP income/assets/expenses + accepts an IEVS claim, versioned + attributed, feeding a determination that snapshots its inputs) and Track 2 — post-UAT correctness (the rest). The "SNAP-UAT-critical" framing is corrected: prior SNAP UAT was demonstrated against seeded data; Track 1 closes that known fact-authoring gap.
-
Snapshot fidelity (v1) = flat input snapshot + per-fact provenance + resolved policy params + ruleset corpus content-hash. The self-explaining derivation graph is v2 (Track 2).
-
Audit integrity (v1) = attributable + reconstructable (attributed events). NOT claimed cryptographically tamper-evident; the signed snapshot carries determination-input integrity. The ADR-014 chain-hash extension is Track 2 (T2-5).
-
Snapshot ownership = assembled + signed inside the program service; the orchestrator receives outcome + a snapshot hash only (preserves ADR-002; FTI-safe by construction).
-
Redaction (v1) = forward-correct + keep raw identity values out of event payloads; true crypto-shred purge is Track 2 (T2-6).
Scope
Track 1 (in scope): canopy-contracts-facts; canopy-rules corpus-hash; SNAP-scoped valid-time versioning of income/assets/expenses (expand-contract) + claim pipeline + assets/expenses parity; attributed events + the change-history endpoint (attributable); worker fact-authoring UI + policy-aware verbs + #632 + CLI parity; IEVS accept/reject write-back; the flat SNAP determination snapshot.
Track 2 (in scope, deferred): household/address versioning + supersession; the derivation-edge graph + per-rule versioning; cross-program + SOLQ/FDSH snapshot capture; FTI-bearing snapshots + program fan-out + ADR-014 chain entry; the chain-hash hardening; crypto-shred redaction + key retention; materiality→recert + notices; appeals/overpayment consumers.
Out of scope (both tracks): SAVE/FDSH/SSA claim adapters beyond the IEVS exemplar (additive later); a first-class case aggregate (ADR-027 §7); multi-jurisdiction change-reporting variation; the applicant-portal Dioxus rewrite (ADR-008).
Design
The claim/fact model (T1-2)
New crates/canopy-contracts-facts (sibling of canopy-validators): Author { Worker { sub: KeycloakSub } | Applicant { household_id: HouseholdId } | System } (System unconstructable as fact-content author — unit-tested), ClaimStatus { Proposed | AcceptedUnverified | AcceptedVerified | Rejected }, Provenance { source, author, origin, proposed_value, status, recorded_at }, Claim<T>, FactVersion<T>. Auto-accept (keyed off the author, not the source): applicant→AcceptedUnverified, worker→AcceptedVerified, automated/no-human-author→Proposed.
As-built deviations (T1-2 / #670, 2026-06-04) — plan amended per ADR-013:
-
No bespoke
ClaimSourceenum.Provenance.sourcereuses the existingcanopy_reference::VerificationSource(SelfAttestation/Ievs/Save/Fdsh/SsaSolq/StateWageRecord/DocumentReview/CollateralContact/CrossProgramQuery/ProviderRegistry), which already enumerates exactly the provenance origins — per the project "don’t re-invent" convention. The crate re-exports it ascanopy_contracts_facts::VerificationSource. -
Author is
Option<Author>onProvenance. Aproposedautomated lead has no human author of record (ADR-027 §1), so the author is optional (None⇔Proposed); theSystem-sealing is a privateauthorfield + constructors (authoredrejectsSystem;system_backfillis the onlySystempath) rather than a_witnessmarker. -
Added
BackfillStatus(accepted-only) +FactVersion<T>.system_backfilltakes aBackfillStatusso a(System, Proposed/Rejected)provenance is unconstructible;FactVersion<T>(value + provenance + valid-time) is the stored/snapshot leaf consumed by T1-3/T1-4/T1-10.HouseholdId/KeycloakSubreusecanopy-common/a String newtype (nocanopy-authdep). Persistence identity (version_id/fact_id/superseded_at/person_id/btree_gist) stays in T1-3, not the wire contract.
Valid-time, append-only versioning + the correction algorithm (T1-3, T1-4)
Today income::update (services/canopy-persons/src/store/income.rs:68-101) overwrites in place via COALESCE; income::soft_delete (:107-125) sets active=false, end_date=CURRENT_DATE; assets/expenses have no update/delete and no effective_date (the create migration is services/canopy-persons/migrations/20260326000000_create_persons_tables.sql:61-99). Reshape income/assets/expenses into append-only version tables. Representative columns:
fact_id UUID NOT NULL, -- stable identity across versions
version_id UUID PRIMARY KEY,
person_id UUID NOT NULL,
valid_from DATE NOT NULL,
valid_to DATE, -- NULL = open-ended
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
superseded_at TIMESTAMPTZ, -- NULL = current record-version
author_type TEXT NOT NULL, author_id TEXT NOT NULL,
claim_source TEXT NOT NULL, claim_status TEXT NOT NULL,
proposed_value JSONB, -- original automated value when worker-accepted
-- ...fact-specific value columns
-
Read rule:
claim_status IN ('accepted_unverified','accepted_verified') AND superseded_at IS NULL AND valid_from ⇐ D AND (valid_to IS NULL OR valid_to > D). Theclaim_statusfilter is mandatory — aproposedIEVS claim must never reach a determination. -
Correction algorithm (one transaction): supersede every current version whose valid-time the correction overlaps, then re-insert the unaffected sub-ranges of those superseded versions as new current versions. Enforce non-overlap with a
btree_gistexclusion constraint on(fact_id, daterange(valid_from, valid_to)) WHERE superseded_at IS NULL AND claim_status LIKE 'accepted%'. Serialize concurrent appends perfact_id. -
Backfill (per-table, forward-only):
fact_id = existing id; income usesvalid_from = effective_date;assets/expenseshave no world-date — backfillvalid_from = created_at::dateand mark v1 provenancesystem/unknown_valid_fromso downstream as-of/overpayment logic treats them as imprecise (do not apply income’s rule to a nonexistent column). -
Expand-contract (ADR-016): (T1-3) add tables + backfill + dual-write shim, old reads still work; (T1-4) repoint reads to as-of + DTO provenance + parity; later contract MR drops legacy columns. Cross-service ID stability (ADR-025): the external handle remains
fact_id.
T1-3 (#671) implementation spec — grounded + adversarially reviewed (2026-06-04; DDL empirically verified on postgres:18-alpine). New tables income_versions / asset_versions / expense_versions (per-fact value columns mirror today’s business columns). Column types map 1:1 to canopy-contracts-facts (add it as a canopy-persons dep — not present today): author_type/author_id ← Author internal-tag (worker→sub, applicant→household_id); claim_source ← VerificationSource snake_case; claim_status ← ClaimStatus snake_case; proposed_value ← JSONB. The store uses runtime query_as (no query! macros / no .sqlx cache) — correctness rides on real-PG integration tests, not compile-time column checks.
Re-scope (review fix): the ongoing-write dual-write moves to T1-4. T1-3 is a clean, low-risk storage expansion: version tables + one-time backfill + the non-overlap constraint + the seed-path fix. It does not refactor the write store fns, add the per-fact_id advisory lock, or run the correction algorithm — because the legacy write path carries no worker identity (the X-Canopy-Actor on-behalf-of mechanism, ADR-019/Claims.actor, exists but is not wired into canopy-web→canopy-persons; handlers only require_service_caller), so mirroring ongoing worker edits in T1-3 could only mislabel them System-authored — a contract violation (Author::System is fact-content-forbidden). Instead T1-4 does the authored dual-write (writes append versions carrying the real Author from the claim pipeline / DTO provenance), re-syncs any T1-3-window legacy writes at the read-flip cutover, then flips reads to as-of. T1-3 reads stay on legacy active=true; the version corpus is the backfill snapshot until T1-4. Provenance::system_backfill (the only Author::System path) is used only for the one-time backfill + the seed emit — its legitimate, documented use.
-
btree_gist: the migration
CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA public(schema-qualified so thegist_uuid_opsoperator class resolves under theEphemeralSchematest harness’s per-schemasearch_path, which excludespublic). The exclusion daterange is half-opendaterange(valid_from, valid_to, '[)')to matchFactVersion::is_live_as_of’s exclusive `valid_to > D;NULL valid_to→ unbounded-upper. The constraint is non-deferrable (immediate) so a same-tx supersede-before-insert is enforced at statement boundaries.EXCLUDE USING gist (fact_id WITH =, daterange(valid_from, valid_to, '[)') WITH &&) WHERE (superseded_at IS NULL AND claim_status LIKE 'accepted%')— the predicate stays in lockstep withClaimStatus::is_accepted(kept distinct fromfeeds_determination). Also addCHECK (valid_to IS NULL OR valid_to > valid_from)to reject backwards/empty ranges at the source. -
Backfill (per-table, asymmetric — in the migration):
fact_id = existing row id. (1) incomevalid_from = effective_date;assets/expenseshave no world-date →valid_from = created_at::date+ originunknown_valid_from(do not invent aneffective_date). (2) status from the legacyverifiedflag (income/assets:true→accepted_verifiedelseaccepted_unverified) — expenses have noverifiedcolumn → defaultaccepted_unverified. (3) Soft-deleted (active=false) rows are a valid-time close, not a transaction-time supersede: setvalid_to = end_date(income) /created_at::date(assets/expenses) withsuperseded_atstaying NULL — the row remains the current record-version of a now-closed window, still visible to an as-of-before-deletion read (overpayment/appeals, ADR-027 §5/§8).superseded_atis reserved strictly for corrections (T1-4). (4) Guard the backwards range a future-effective soft-deleted income produces (income::soft_deletestampsend_date=CURRENT_DATEunconditionally →end_date < effective_date): clampvalid_to = GREATEST(end_date, effective_date)(theCHECKis the backstop). Backfill is idempotent/re-run-safe (guard against duplicate current versions). All viaProvenance::system_backfill(BackfillStatusaccepted-only → constraint-safe by construction). -
Seed-path fix (review catch — a second, non-HTTP writer):
tools/canopy-seed/src/sql.rswrites income/assets/expenses via rawINSERTandTRUNCATE`s only the legacy tables (`render_persons:161-175; income :307 / assets :337 / expenses :364), soseed --resetwould leave the version tables empty/orphaned. Extendrender_personsto also emit the*_versionsrows (backfill-equivalent,system_backfillprovenance — legitimate; seed data is system-generated) and add the three version tables to itsTRUNCATEset. Add a test thatseed --resetleaves legacy + version tables coherent. -
Frozen in T1-3 (→ T1-4/#672): the wire DTOs (
Income/Asset/Expense,MemberFull) + theirFrom<Row>projections stay byte-stable (reads still served from legacyactive=true); provenance-over-the-wire, assets/expenses PUT/DELETE, the claim/accept-reject pipeline, the authored dual-write + correction algorithm + advisory lock, and the as-of read are all T1-4. Correct the two now-obsolete "income mutates in place / no versioning layer" comments (services/canopy-web/src/api/income.rs,crates/canopy-contracts-persons/src/income.rs). -
T1-4 read-flip parity caveat (review catch): the legacy income read is purely
active = trueand ignoresend_date, so anactive = trueincome with a pastend_date(a worker can PUT one via the COALESCE update without flippingactive) is live on the legacy path but backfills to a closed window (valid_to = end_date), which an as-of-now version read would miss. Harmless in T1-3 (versions are unread), but T1-4’s read-flip + parity check must reconcile it — either treatactive = trueas the authoritative open window regardless ofend_date, or accept the world-time-correct closed window. Pin with a parity fixture in T1-4. -
Acceptance tests (DB-level via
EphemeralSchema::new_for_persons+ rawquery_ason*_versions— the version state is invisible through the deliberately-frozen T1-3 HTTP API): per-table backfill correctness (incomeeffective_date; assets/expensesunknown_valid_from; expenses defaultaccepted_unverified); soft-deleted row →valid_toclosed withsuperseded_atNULL (and an as-of-before-deletion read would still see it); a future-effective soft-deleted income backfills without aborting (the clamp/CHECK); the constraint rejects an overlapping current accepted version; afact_idwith N legacy rows (1 active + (N-1) soft-deleted) backfills to one current + (N-1) closed non-overlapping versions; backfill idempotency on re-run; theCREATE EXTENSIONworks across two ephemeral schemas (the operator-class/search_path regression);seed --resetcoherence.
As-built — T1-4 (#672) is sliced into three forward-only MRs (2026-06-17, ADR-013 §5 / ADR-016 expand-contract). The issue as written is one ~2000-LOC correctness-critical MR (as-of read-flip + claim_status filter + claim→accept/reject pipeline + correction algorithm + per-fact_id advisory lock + assets/expenses PUT/DELETE + DTO provenance + ?as_of + CLI parity) — too large to review well or hand to a contextless implementer. Split, each independently green:
-
Slice 1 — assets/expenses write parity (Done 2026-06-17). Partial-update
PUT+ soft-deleteDELETEon/v1/persons/{id}/{assets,expenses}/{id}, mirroring income’s 446, legacy-table-only (no versioning/provenance/as-of/events — reads untouched).Update{Asset,Expense}DTOs (asset carriesverified/verification_source, expense doesn’t — legacy column asymmetry); soft-delete is a plainactive=falseflip (noend_datecolumn on those tables). ADR-007 CLI parity made uniform (assetupdate/delete, a wholeexpensecommand, the pre-existing income update/delete CLI gap closed via typed partial-patch bodies — no newserde_json::Value/[allow]), and the malformedasset add/listCLI fixed. The symmetric write surface Slices 2-3 build on; zero determination-path risk. -
Slice 2 — authored versioned write + correction algorithm (Done 2026-06-18). The
POST /v1/persons/{id}/{income,assets,expenses}/claimsendpoints append to the*_versionscorpus carrying a realAuthoringested in the claim/request body’s provenance inputs (source+author+origin; the server derivesclaim_status+ ownsrecorded_at;Systemauthor → 422 — the handler re-checks because derivedDeserializebypasses theProvenance::authoredguard; negative amount/value → 422). The correction algorithm runs under a per-fact_idadvisory lock (pg_advisory_xact_lock(advisory_lock_id("canopy-persons.fact_version.{table}.{fact_id}"))): snapshot the overlapped current-accepted versionsFOR UPDATE→ supersede them (transaction-time close;valid_tountouched) → re-tile the unaffected left/right remnants (half-open[), value+provenance copied verbatim) → insert the correction;recorded_atis stamped post-lock + bound explicitly on every row; a residual23P01→409, a valueCHECK23514→422. First-write is the degenerate no-overlap case (one code path). Version-corpus-ONLY: the legacy tables, their reads, and the legacy write door are untouched (/claimsdoes NOT write legacy + needs no generic-executor refactor) — the version corpus is write-only in Slice 2 (a correction bug cannot reach a live determination), and authored facts begin feeding determinations at the Slice-3 read-flip. ADR-007 CLI parity:canopy {income,asset,expense} claim. ⚠ Re-slice (ADR-013, 2026-06-18): this NOTE originally placedclaim→accept/reject endpointsin Slice 2; they are moved to T1-9 (where their producer — the IEVS adapter that yields Proposed leads — lands), because shipping them in Slice 2 (no lead source) would be untestable dead code. attributed events stay T1-5; the version row carries full attribution so the system-of-record is complete. -
Slice 3 — the sole-fact-store cutover (Done 2026-06-18, closes #672). The determination read flips onto the version corpus AND the legacy fact storage is removed. Built (deviations from the original note, ADR-013):
-
Reads flip — all four endpoints, not just
/full. Per-personGET /{income,assets,expenses},/households/{id}/full, and:batchGetall read the current-accepted version valid onas_of(list_by_persons_as_of+ three explicit per-type mappers +reconstruct_provenance),claim_statusfiltered viaClaimStatus::feeds_determination(Proposed/Rejected never feed a determination). Flipping the endpoints fixes every consumer (Medicaid income test, T-MSIS/CMS-416 reporting, orchestrator) with no consumer-code change.Income.id→fact_id(stable across corrections); DTOs carry additiveprovenance.?as_ofhonoured (was 501;resolve_as_of, malformed→400); the orchestrator forwardsas_of. Corpus-corrupt provenance (ADR-027 §1–§2 inconsistent) → 500 + alert, never silently served. -
Re-sync was IMPOSSIBLE → writer cutover instead. The original note proposed "re-sync of the legacy-window writes." A legacy-door projection carries no worker identity (the BFF strips it) →
System-authored →Proposed→ filtered out AND an ADR-027 §1 violation. So the only correct write is/claimswith a realAuthor: the canopy-web income editor (add→new, edit→full-window correction via hidden value carriers, remove→close) + applicantfinalizecut over, carrying the workersub/ applicanthousehold_id. (This subsumes the deferred-T1-8 editor wiring into Slice 3.) -
Remove needs a CLOSE primitive, not a correction. A bounded-
valid_tocorrection re-tiles a right remnant and does NOT remove (proven by thecompute_remnantstest). Addedclose_income_version+DELETE /v1/persons/{id}/income/claims/{fact_id}(supersede + left-remnant-only; idempotent 204). -
Folded HIGH bugfix (latent in merged Slice 2):
snapshot_and_supersedefilteredfact_idonly → a wrong{person_id, fact_id}could cross-person-corrupt;person_idadded to the WHERE (all three) + a handler ownership probe (404, ADR-027). -
Legacy DELETED (user directive, pre-1.0 no back-compat): the 6 legacy write endpoints + handlers + legacy store modules + Row→DTO conversions +
Create*/Update*DTOs + legacy CLI/test-lib write methods; seed writes*_versionsdirectly; the CONTRACT migration (ADR-016; T1-3 was the expand) drops the legacy tables +backfill_fact_versions_v1()(function-before-table). ADR-007:income claim-delete+household get-full --as-of.
-
#672 is closed by Slice 3.
Claim pipeline, attributed events, history (T1-4, T1-5, T1-6)
Claim ingest applies the auto-accept rule; accept-of-proposed appends an AcceptedVerified version (worker author, origin+proposed_value preserved); reject appends no version but emits an event. Attributed {income,asset,expense,household_member}.{claimed,accepted,rejected} events carry author/source/before/after in the typed payload (the EventEnvelope is not extended — it has no actor fields today) via the ADR-018 outbox; the finalize fan-out emits one event per fact — per-fact, unbatched: the events are audit-only (no re-determination subscriber), so no summary event is added (D1 no-dead-code; resolved in T1-7). canopy-security records them (attributable; chain-hash hardening is T2-5) and exposes GET /v1/security/household/{id}/{resource} (caseworker-scoped). Proposed claims surface in the existing pending-verifications / IEVS-alerts panel — not a new queue.
As-built — T1-5 (#673, 2026-06-19). Shipped the typed attributed events through the ADR-018 outbox from the canopy-persons write handlers, atomically with the fact write (the event commits iff the fact does):
-
Scope shipped = the 3
.claimed+income.closed(decision D1: emit only where a firing site exists, no dead code). Each claim handler (income/asset/expense) stages a*.claimedevent; the income close (the D10 primitive) stagesincome.closedonly when it superseded ≥1 window (a no-op reclose emits nothing). The store write path returns the complete set of superseded accepted windows (AppendOutcome { version_id, before }for append; aVec<…BeforeWindow>for close), capturedFOR UPDATEunder the per-fact_idlock — sobeforeis the whole superseded set (a multi-window/gap-start/future-effective supersession carries them all), not a single from-covering value, andafteris the new value. Payloads reuse the*FactValueshapes; no PII (ADR-027 §8 / ADR-004),Decimalas a JCS-stable string. -
income.closedcarriesauthor: None— a service-to-service DELETE has no per-worker subject and synthesizing one would be false attribution; the human actor on a close awaits the ADR-019 on-behalf-of plumbing (a documented bounded limitation;source_servicestill records canopy-persons). Claims ARE fully attributed. -
income.closedis a deliberate vocabulary extension beyond ADR-027 §4’s{claimed,accepted,rejected}list (which predates the T1-4 D10 close primitive). Per ADR-027 §1 "ADRs are immutable once accepted" there is no in-place ADR edit and one event name does not warrant a new ADR — it is recorded here (mirroring how the D10 primitive itself was documented) + the Antora Events Published page + CHANGELOG. A future formal §4-list extension would be its own ADR, not blocking. -
canopy-security audit parser updated (commit c-sec) — fact events index by
fact_id(prepended to theresource_idcandidates) + actor from the nestedauthor.sub/author_type; without this the audit row would be person-id-keyed with no actor. -
Deferrals (tracking issues, not buried): accept/reject → T1-9 (#677, their IEVS producer); asset/expense close → #562 (their close primitives); batched finalize → resolved in T1-7 (#675): shipped per-fact unbatched (events are audit-only — no re-determination subscriber — so no summary event is added);
household_member.stays on the existing non-attributedhousehold.member_events. -
Commit (b) store + (c) emission were folded into one commit (deviation from the execution-plan’s a→e breakdown): a stored
beforewindow that nothing reads is dead code under-D dead_code, so the capture and the emission that consumes it are one indivisible change. CLI parity needs no change (income claim/claim-delete + asset/expense claim already exist from T1-4).
Snapshot (T1-10) — flat, program-service-signed
canopy-contracts-eligibility gains a flat DeterminationSnapshot (proven facts + provenance + resolved policy params + the exact evaluated input + corpus-hash). The program service assembles + signs it: SHA-256 over RFC 8785 canonical bytes → snapshot_hash field added to canopy-signing::SignableDetermination. Stored immutably in the program DB; orchestrator gets outcome + hash only. Legacy determinations carry a no_input_snapshot marker.
As-built (Done 2026-06-20). Realised in two MRs: MR1 (!648, 0970548f) the additive contract + signing + corpus-hash foundation; MR2 (d4b55b9d) the canopy-snap assembly + immutable storage. Reconciliations from the sketch above: (1) the snapshot_hash change is NOT a 5-service change — Option<String> + skip_serializing_if + default-None in build() makes the verifier + the other four programs byte-unchanged automatically (the ADR-035 person_id precedent); only canopy-signing (the field) + canopy-snap (populates it) change. (2) There is no live legacy serde_json::to_vec signer to retire — canonical_signing_payload() already used serde_jcs (#387); the orphaned Determination/Ecdsa*Signer in canopy-eligibility/src/determination.rs (zero callers, ADR-028 §50) is deleted in a follow-up, not here. (3) The /v1/determine context did not need enriching — the orchestrator already forwards the full provenance-bearing persons facts inside the income/asset/expense arrays; canopy-snap recovers provenance + as_of + fact-ids by re-parsing the raw body into tolerant RichInputs (the lean math context is unchanged). (4) The raw IEVS figure stays snap-local (ADR-004 §2025(e)); the snapshot joins it from ievs_discrepancies by provenance.origin = ievs:{id} (the T1-9 → T1-10 design). The snapshot_hash is durably stored on snap_determinations (re-verifiable; its absence is the legacy marker). Track-2 consumers (appeals replay, overpayment recalc, supersession, the §39 policy-version stamp, the §53 retention window, the ProgramResult receipt) are filed follow-ups.
MRs
Each MR: branch from synced main; unit + integration tests; a fresh diff-only review subagent before commit; cargo xtask validate + live cargo xtask e2e; two-stage signed commit; MR via glab; force-merge per the project workflow conventions (Contributor Workflow); flip the Status row in the same MR; CLI parity per ADR-007 where the MR adds an endpoint.
Track 1 — SNAP-UAT-minimum
T1-1 canopy-rules corpus-hash (#682). Deps: none. canopy-rules computes a SHA-256 of the loaded JDM corpus at startup and returns it on every /evaluate; program services thread it to the snapshot. Done: every eval response carries the corpus hash; stable across restarts for an unchanged corpus.
T1-2 canopy-contracts-facts (#670). Deps: none. The types above. Done: compiles, serde round-trips, Author::System cannot author content via the handler API.
T1-3 persons expand (#671). Deps: T1-2. Version tables + backfill + non-overlap exclusion constraint + dual-write shim; old overwrite-based reads still work. Done: new versions land; backfill correct per-table; old callers unbroken in the dual-write window.
T1-4 persons repoint + pipeline (#672). Deps: T1-3. Sliced (see the Design as-built NOTE): Slice 1 = assets/expenses PUT/DELETE parity; Slice 2 = the authored versioned write + correction algorithm + per-fact_id advisory lock (the human-authored /claims arm, auto-accept); Slice 3 = as-of reads with the claim_status filter + DTO provenance over the wire + ?as_of param. The Proposed-lead accept/reject arm is re-sliced to T1-9 (its IEVS producer). Done (overall): as-of read returns the version true on a date after a later correction; a proposed claim never appears in fetch_household_context; provenance returned on GET.
T1-5 attributed events (#673). Deps: T1-4. Payload-attributed fact events through the outbox. Done (2026-06-19): each claim emits one attributed *.claimed event + the income close emits income.closed (the shipped scope per as-built D1 — accept/reject deferred to T1-9, batched finalize to T1-7, asset/expense close to #562); CLI parity already satisfied by T1-4 (no new commands). See the T1-5 as-built note.
T1-6 security history (#674). Deps: T1-5. The scoped change-history endpoint (attributable). (The Proposed-claim count/badge feed was re-sliced to T1-9 #677 — it needs the IEVS Proposed producer.) Done: ordered history returned, caseworker-scoped.
T1-7 applications finalize→claims (#675). Deps: T1-2, T1-4. finalize authors applicant claims (auto-accepted unverified) incl. assets/expenses; HouseholdRef author = resolved household_id, no portal-session identity in events (ADR-026). Done: finalize produces attributed accepted-unverified versions; event hygiene asserted.
T1-8 worker fact-authoring UI (#676). Deps: T1-4, T1-7. Case-detail editors for persons/member/income/asset/expense (replacing #562 stubs); PUT=reported-change / POST=everything-else; #632 gate; Proposed-claim inbox surface; CLI. Done: worker authors each fact type; scope gate denies out-of-scope writes.
T1-9 IEVS write-back (#677). Done (2026-06-20) — ADR-004-clean realization. Deps: T1-4, T1-8. IEVS resolve → worker accept/reject; the cross-service writer is canopy-web (mirroring the income editor). As-built deviation from the original "non-lossy proposed_value in persons" wording (ADR-013): writing the raw IEVS figure into the shared canopy-persons store collides with ADR-004 §2025(e) (IEVS data must not be available to non-SNAP services). So accept writes only the worker-verified value via the existing /claims endpoint, tagged source=ievs + origin=ievs:{discrepancy_id} (verification-method metadata, no figure — proposed_value stays NULL in persons); the raw figure + the "IEVS said X → worker verified Y" reconstruction stay snap-local (the ievs_discrepancies row), and the T1-10 snapshot (snap-assembled) joins them — satisfying ADR-027 §2’s intent (self-contained leaf, no canopy-security dependency) without the shared-store exposure. The accept is persons-first with origin-based idempotent fact resolution. Reject writes no fact; it flips the snap discrepancy + emits an attributed ievs.discrepancy_resolved event (actor + IDs, no figure). The IEVS noop integration tests are unaffected (the global IEVS_ADAPTER is not flipped; the verification flow is untouched). The inbox is the existing IEVS-alerts panel (deep-linked to the income tab). No canopy-persons/contracts change was needed. See the T1-9 plan.
T1-10 SNAP snapshot (#678). Deps: T1-1, T1-2, T1-4. The flat snapshot + snapshot_hash signing + immutable storage in canopy-snap + as-of assembly for SNAP + no_input_snapshot legacy marker. Done: every SNAP determination persists an immutable, signature-bound flat snapshot with provenance + policy params + corpus-hash; no UPDATE path.
Track 2 — post-UAT correctness
T2-1 household/address versioning + supersession (#683). Deps: T1-4. Extend versioning to household_members/addresses (identity rules) + previous_determination_id/effective-period chaining. Sliced into three Relates to #683 MRs: A1 address valid-time versioning (Done (2026-06-22) — merged 50ad5f65; address_versions corpus + claim/close + as-of reads + street-redacted events; legacy addresses dropped in the T2-1 CONTRACT (#890, 20260625000000)), A2 household_members versioning (Done (2026-06-22) — household_member_versions corpus with the per-(household_id, person_id) non-overlap EXCLUDE + claim/close + as-of household reads + the as-of-aware Person.household_id projection + attributed household.member_claimed/member_closed events replacing the old member_added/member_removed; legacy household_members dropped in the T2-1 CONTRACT (#890, 20260625000000)), Half B determination supersession (Done (2026-06-22) — closes #683). ADR-028 §57: a signed, tamper-evident previous_determination_id on the universal SignableDetermination (skip-if-none, all five programs) + a nullable snap_determinations self-FK with a one-to-one partial-unique chain index; supersession is derived (the GET /v1/determinations/{id} + list read views surface superseded_by_id/superseded_as_of via a LEFT JOIN — COALESCE(superseder.effective_date, superseder-snapshot.as_of) so a denial superseder still dates) with no mutation of the immutable prior row; the §57 cross-service read is the new GET /v1/determinations/{id}/snapshot (the frozen DeterminationSnapshot, service/admin/QC only, tri-state 404/404-legacy/500-corrupt). Decision A (no-fig-leaf): the link is explicit/optional (canopy-snap is told its antecedent, never infers it); the production trigger (orchestrator resolves the operative antecedent on a recert + persists it on program_determinations) is genuinely T2-7 (#680) — adding an orchestrator column with no writer would be storage-with-no-writer (mirrors the #879 receipt deferral). Decision B (architecture, not the T1-10 precedent): the program-specific feature is SNAP-only because SNAP is the only program that is both non-FTI and orchestrator-live — tanf/medicaid need a hearing-scoped in-boundary read (FTI; T2-8/#681) and caps/wic are not orchestrator-reachable; the shared envelope field still lands for all five (the snapshot-read #882 is implemented for SNAP). See the T2-1 A1 plan + the T2-1 A2 plan + the T2-1 Half B plan.
T2-2 snapshot v2 graph (#679). Deps: T1-10. Derivation edges + per-rule versioning (the self-explaining fact graph); #669 deprivation/utility frozen as derived facts flagged provisional.
T2-3 cross-program capture (#684). Deps: T1-10. Done (2026-06-21). Scope-reconciled against code reality: the one genuinely-unfrozen consumed input was raw SOLQ — now frozen by value in the typed cross_program_inputs.solq (schema_version 2; ADR-028 Amendment 1). EE15 assigned_coa (own output), ELE (ele_grant_events), and the TMA inputs were already captured. The TMA upstream determination id by-reference (needs a tanf.case_closed contract change) and FDSH capture (not yet consumed by determine()) are deferred to follow-ups.
T2-4 FTI snapshots + fan-out (#685). Deps: T1-10. tanf/medicaid/caps/wic snapshots; tanf/medicaid FTI-bearing snapshots join the ADR-014 chain (tenancy + §9 breach pathway); supersede tanf_household_snapshots/application_context via expand-contract.
T2-5 audit hardening (#686). Done (2026-06-21). Deps: T1-5. A per-row-versioned audit_events hash: v2 (hash_version = 2) is the RFC 8785 (JCS) canonical bytes of a typed input struct covering the actor + action + resource + source_service + household_id + a content-hash of metadata (before/after); v1 stays byte-stable for historical rows via per-row dispatch. The metadata is normalized through Postgres (SELECT $1::jsonb) before hashing so insert/verify match; ordering uses created_at, id. ADR-014 Amendment 1; the change-history is now cryptographically tamper-evident (server-side verify_chain). See the T2-5 plan.
T2-6 redaction + key retention (#687). Deps: T1-3, T1-5. Crypto-shred per-value redaction across facts/events/snapshots (chain stays verifiable) + JWS verification-key retention beyond JWKS rotation.
T2-7 materiality → recert + notices (#680). Deps: T1-7, T1-10. The orchestrator non-persisting dry-run mode (pinned to the snapshot’s corpus version) + the net-new renewals→eligibility call path + the worker-actioned recert nudge + ChangeInCircumstancesNotice. Materiality predicate defined with exact-value tests (verdict change; benefit delta ≥ threshold sourced from jurisdiction.toml).
T2-8 appeals + overpayment (#681). Deps: T1-10. Appeals reads the frozen snapshot in-boundary for FTI programs (program service exposes a hearing-scoped read; FTI never enters canopy-appeals/reporting); overpayment recompute runs in the owning program service (canopy-reporting only rolls up); OverpaymentNotice; provisional-derived snapshots excluded from automated recovery.
Files Touched
| Area | Change |
|---|---|
|
Claim/Author/Provenance/FactVersion types (T1-2). |
|
|
|
Corpus content-hash on |
|
Append-only versioned store + claim pipeline + assets/expenses parity + attributed events (T1-3…T1-5, T2-1, T2-6). |
|
Change-history endpoint + Proposed-claim feed; chain-hash hardening (T1-6, T2-5). |
|
finalize→claims + ADR-026 hygiene (T1-7). |
|
Worker fact-authoring UI + policy-aware verbs + IEVS accept/reject (T1-8, T1-9). |
|
SNAP snapshot (T1-10); FTI/fan-out (T2-4). |
|
Enriched |
|
Materiality/recert, appeals replay, overpayment, notices (T2-7, T2-8). |
|
CLI parity for every new endpoint (ADR-007), per MR. |
Verification
Per MR: cargo nextest run --workspace --lib → cargo xtask dev refresh (or restart for schema MRs) → cargo nextest run --workspace → cargo xtask e2e → cargo xtask validate (incl. docs plan-lint). Specific correctness tests: a retroactive-correction fixture asserting as_of(D) is stable after a later-recorded correction; an immutability test asserting an UPDATE on a snapshot row is rejected; a concurrency test for two simultaneous appends to one fact_id; an exact-value materiality test (T2-7); a named gated E2E for the worker fact-authoring walk. Track-1 acceptance: a worker authors a member + income + asset, accepts an IEVS discrepancy, runs a SNAP determination, and the frozen snapshot reproduces the exact inputs with provenance + corpus-hash; canopy-security shows the attributed change history.
Documentation Updates
-
Service Catalog —
canopy-contracts-facts; persons fact-versioning + claim endpoints; security history endpoint; per-service event/table additions. -
Antora per-service API + data-model pages — persons fact versions, claim endpoints, snapshot tables, canopy-rules corpus-hash.
-
CHANGELOG.adoc— entry per MR under== Unreleased. -
tools/canopy-clireference — new subcommands. -
ADR-027 / ADR-028 ratified
Accepted(2026-06-02).