Plan: Worker Fact Authoring and Provenance

On this page
NOTE

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 /evaluate (snapshot prerequisite).

Done (2026-06-04) — SHA-256 over the name-sorted effective corpus on every EvaluateResponse.

T1-2 (#670)

canopy-contracts-facts crate — Claim, Author, Provenance, FactVersion types.

Done (2026-06-04) — reuses VerificationSource (no ClaimSource); see Design note.

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 Author) + correction algorithm + per-fact_id advisory lock + as-of read-flip (claim_status filter) + DTO provenance. (accept/reject re-sliced to T1-9, ADR-013.)

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) — income/asset/expense.claimed + income.closed emitted from the persons write handlers via the ADR-018 outbox; non-lossy before/after (the whole superseded set) captured under the lock; typed payloads (no PII); canopy-security indexes by fact_id + author.sub. Scope shipped = the 3 .claimed + income.closed (see the T1-5 as-built note in the Design section); accept/reject deferred to T1-9, asset/expense close to #562, batched finalize to T1-7. CLI parity already satisfied by T1-4 (no CLI change). (#673)

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 Proposed producer.)

Done (2026-06-19) — person-scoped GET /v1/security/persons/{id}/fact-history/{resource} over the T1-5 audit events + a canopy-web BFF household-composed read + a read-only case-detail "Change history" section + canopy security fact-history CLI. (#674)

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 (Author::applicant, auto-accepted unverified), emitting attributed *.claimed events; per-fact unbatched (audit-only, D1); facts pre-validated before any cross-service write. End-to-end portal→asset-fact wiring is a documented non-goal (the portal sends none, as with income). (#675)

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 PUT=reported-change → Track 2 per ADR-027 §5 (#868); Proposed-claim inbox → T1-9 #677; income editor already shipped in T1-4; CLI person update/update-member endpoint/secure-SSN → #869/#870/#871. See T1-8 plan.)

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 source=ievs); the inbox = the existing IEVS-alerts panel. T1-9 plan.

Done (2026-06-20) — MR1 accept/reject write-back (!646, 6bf9e3a5) + MR2 inbox deep-link + CLI parity.

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 (0970548f) foundation + MR2 (d4b55b9d) snap assembly/storage.

Track 2 — post-UAT correctness

T2-1 (#683)

canopy-persons: household_members + addresses versioning + determination supersession (previous_determination_id + effective period). Sliced (each a Relates to #683 MR; Half B closes it): A1 addresses, A2 household_members, Half B determination supersession. See the T2-1 A1 plan.

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 derivation_graph on their determination snapshot (schema_version: 3, ADR-028 Amendment 2): every derived fact’s value + its input edges + the versioned rule (RuleRef @ corpus_hash) or Rust fn (service_version) that produced it; #669 inferred SNAP utility / TANF deprivation frozen as provisional nodes. Delivered as 8 dependency-sliced MRs + a shared-helper refactor + the #903 envelope-root fix. Deferred follow-ups (filed, /relate #679): Medicaid TMA-upstream-id by-ref + FDSH edges (blocked), denial/cascade edges (#904), the shared-helper DRY hoist (#905), and the full-ToSchema sweep.

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 cross_program_inputs.solq (ADR-028 Amendment 1); TMA-upstream-id + FDSH deferred → follow-ups.

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 audit_events hash (JCS over a typed struct) covers actor + action + resource + source_service + household_id + a metadata content-hash; per-row hash_version keeps v1 byte-stable. ADR-014 Amendment 1.

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+[snap.materiality] threshold; MR2 canopy-rules corpus-version replay + ephemeral ?audit=false eval; MR3 canopy-snap write-free dry-run + full-bundle snapshot enrichment; MR4 eligibility dry-run orchestration; MR5 renewals materiality subscriber + recert_nudges + ChangeInCircumstancesNotice; MR6 worker surface (case-detail renewals tab + canopy eligibility dry-run / renewals nudge {list,action}) + gated E2E + as-built docs. See the T2-7 plan.

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 (POST /v1/determinations/{id}/overpayment-recompute: idempotent replay of the frozen snapshot against corrected facts → per-month sizing → #382 claim + overpayment_recomputes audit row + snap.overpayment_claimedOverpaymentNotice, all in canopy-snap so FTI never crosses); the hearing-scoped FTI-safe projection GET /v1/determinations/{id}/hearing-view (appeals reads it in-boundary via a SnapHearingClient); provisional-derived exclusion + overlapping-claim guard; worker-portal action + hearing-view display + canopy snap {overpayment recompute, determination hearing-view} CLI; gated Playwright E2E; ADR-028 Amendment 4 as-built. See the T2-8 plan.

Epic: &56
Issues: Track 1 #682, #670–#678 · Track 2 #683, #679, #684–#687, #680, #681
ADRs: ADR-027 · ADR-028
Branch: per-MR, feat/fact-authoring-{t1,t2}-{slug}

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)

  1. 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.

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

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

  4. Snapshot ownership = assembled + signed inside the program service; the orchestrator receives outcome + a snapshot hash only (preserves ADR-002; FTI-safe by construction).

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

NOTE

As-built deviations (T1-2 / #670, 2026-06-04) — plan amended per ADR-013:

  • No bespoke ClaimSource enum. Provenance.source reuses the existing canopy_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 as canopy_contracts_facts::VerificationSource.

  • Author is Option<Author> on Provenance. A proposed automated lead has no human author of record (ADR-027 §1), so the author is optional (NoneProposed); the System-sealing is a private author field + constructors (authored rejects System; system_backfill is the only System path) rather than a _witness marker.

  • Added BackfillStatus (accepted-only) + FactVersion<T>. system_backfill takes a BackfillStatus so 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/KeycloakSub reuse canopy-common/a String newtype (no canopy-auth dep). 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). The claim_status filter is mandatory — a proposed IEVS 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_gist exclusion constraint on (fact_id, daterange(valid_from, valid_to)) WHERE superseded_at IS NULL AND claim_status LIKE 'accepted%'. Serialize concurrent appends per fact_id.

  • Backfill (per-table, forward-only): fact_id = existing id; income uses valid_from = effective_date; assets/expenses have no world-date — backfill valid_from = created_at::date and mark v1 provenance system/unknown_valid_from so 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.

NOTE

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_idAuthor internal-tag (worker→sub, applicant→household_id); claim_sourceVerificationSource snake_case; claim_statusClaimStatus 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 the gist_uuid_ops operator class resolves under the EphemeralSchema test harness’s per-schema search_path, which excludes public). The exclusion daterange is half-open daterange(valid_from, valid_to, '[)') to match FactVersion::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 with ClaimStatus::is_accepted (kept distinct from feeds_determination). Also add CHECK (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) income valid_from = effective_date; assets/expenses have no world-date → valid_from = created_at::date + origin unknown_valid_from (do not invent an effective_date). (2) status from the legacy verified flag (income/assets: true→accepted_verified else accepted_unverified) — expenses have no verified column → default accepted_unverified. (3) Soft-deleted (active=false) rows are a valid-time close, not a transaction-time supersede: set valid_to = end_date (income) / created_at::date (assets/expenses) with superseded_at staying 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_at is reserved strictly for corrections (T1-4). (4) Guard the backwards range a future-effective soft-deleted income produces (income::soft_delete stamps end_date=CURRENT_DATE unconditionally → end_date < effective_date): clamp valid_to = GREATEST(end_date, effective_date) (the CHECK is the backstop). Backfill is idempotent/re-run-safe (guard against duplicate current versions). All via Provenance::system_backfill (BackfillStatus accepted-only → constraint-safe by construction).

  • Seed-path fix (review catch — a second, non-HTTP writer): tools/canopy-seed/src/sql.rs writes income/assets/expenses via raw INSERT and TRUNCATE`s only the legacy tables (`render_persons :161-175; income :307 / assets :337 / expenses :364), so seed --reset would leave the version tables empty/orphaned. Extend render_persons to also emit the *_versions rows (backfill-equivalent, system_backfill provenance — legitimate; seed data is system-generated) and add the three version tables to its TRUNCATE set. Add a test that seed --reset leaves legacy + version tables coherent.

  • Frozen in T1-3 (→ T1-4/#672): the wire DTOs (Income/Asset/Expense, MemberFull) + their From<Row> projections stay byte-stable (reads still served from legacy active=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 = true and ignores end_date, so an active = true income with a past end_date (a worker can PUT one via the COALESCE update without flipping active) 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 treat active = true as the authoritative open window regardless of end_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 + raw query_as on *_versions — the version state is invisible through the deliberately-frozen T1-3 HTTP API): per-table backfill correctness (income effective_date; assets/expenses unknown_valid_from; expenses default accepted_unverified); soft-deleted row → valid_to closed with superseded_at NULL (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; a fact_id with N legacy rows (1 active + (N-1) soft-deleted) backfills to one current + (N-1) closed non-overlapping versions; backfill idempotency on re-run; the CREATE EXTENSION works across two ephemeral schemas (the operator-class/search_path regression); seed --reset coherence.

NOTE

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-delete DELETE on /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 carries verified/verification_source, expense doesn’t — legacy column asymmetry); soft-delete is a plain active=false flip (no end_date column on those tables). ADR-007 CLI parity made uniform (asset update/delete, a whole expense command, the pre-existing income update/delete CLI gap closed via typed partial-patch bodies — no new serde_json::Value/[allow]), and the malformed asset add/list CLI 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}/claims endpoints append to the *_versions corpus carrying a real Author ingested in the claim/request body’s provenance inputs (source + author + origin; the server derives claim_status + owns recorded_at; System author → 422 — the handler re-checks because derived Deserialize bypasses the Provenance::authored guard; negative amount/value → 422). The correction algorithm runs under a per-fact_id advisory lock (pg_advisory_xact_lock(advisory_lock_id("canopy-persons.fact_version.{table}.{fact_id}"))): snapshot the overlapped current-accepted versions FOR UPDATE → supersede them (transaction-time close; valid_to untouched) → re-tile the unaffected left/right remnants (half-open [), value+provenance copied verbatim) → insert the correction; recorded_at is stamped post-lock + bound explicitly on every row; a residual 23P01→409, a value CHECK 23514→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 (/claims does 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 placed claim→accept/reject endpoints in 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-person GET /{income,assets,expenses}, /households/{id}/full, and :batchGet all read the current-accepted version valid on as_of (list_by_persons_as_of + three explicit per-type mappers + reconstruct_provenance), claim_status filtered via ClaimStatus::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.idfact_id (stable across corrections); DTOs carry additive provenance. ?as_of honoured (was 501; resolve_as_of, malformed→400); the orchestrator forwards as_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 /claims with a real Author: the canopy-web income editor (add→new, edit→full-window correction via hidden value carriers, remove→close) + applicant finalize cut over, carrying the worker sub / applicant household_id. (This subsumes the deferred-T1-8 editor wiring into Slice 3.)

    • Remove needs a CLOSE primitive, not a correction. A bounded-valid_to correction re-tiles a right remnant and does NOT remove (proven by the compute_remnants test). Added close_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_supersede filtered fact_id only → a wrong {person_id, fact_id} could cross-person-corrupt; person_id added 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 *_versions directly; 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 *.claimed event; the income close (the D10 primitive) stages income.closed only 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; a Vec<…BeforeWindow> for close), captured FOR UPDATE under the per-fact_id lock — so before is the whole superseded set (a multi-window/gap-start/future-effective supersession carries them all), not a single from-covering value, and after is the new value. Payloads reuse the *FactValue shapes; no PII (ADR-027 §8 / ADR-004), Decimal as a JCS-stable string.

  • income.closed carries author: 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_service still records canopy-persons). Claims ARE fully attributed.

  • income.closed is 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 the resource_id candidates) + actor from the nested author.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-attributed household.member_ events.

  • Commit (b) store + (c) emission were folded into one commit (deviation from the execution-plan’s a→e breakdown): a stored before window 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 retirecanonical_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 figureproposed_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

crates/canopy-contracts-facts (new)

Claim/Author/Provenance/FactVersion types (T1-2).

crates/canopy-contracts-eligibility · crates/canopy-signing

DeterminationSnapshot + snapshot_hash on SignableDetermination (T1-10).

services/canopy-rules

Corpus content-hash on /evaluate (T1-1).

services/canopy-persons

Append-only versioned store + claim pipeline + assets/expenses parity + attributed events (T1-3…T1-5, T2-1, T2-6).

services/canopy-security

Change-history endpoint + Proposed-claim feed; chain-hash hardening (T1-6, T2-5).

services/canopy-applications

finalize→claims + ADR-026 hygiene (T1-7).

services/canopy-web

Worker fact-authoring UI + policy-aware verbs + IEVS accept/reject (T1-8, T1-9).

services/canopy-snap + program services

SNAP snapshot (T1-10); FTI/fan-out (T2-4).

services/canopy-eligibility

Enriched /v1/determine context + hash-only receipt + dry-run mode (T1-10, T2-7).

services/canopy-renewals · canopy-appeals · canopy-reporting · canopy-notices

Materiality/recert, appeals replay, overpayment, notices (T2-7, T2-8).

tools/canopy-cli

CLI parity for every new endpoint (ADR-007), per MR.

Verification

Per MR: cargo nextest run --workspace --libcargo xtask dev refresh (or restart for schema MRs) → cargo nextest run --workspacecargo xtask e2ecargo 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 Catalogcanopy-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-cli reference — new subcommands.

  • ADR-027 / ADR-028 ratified Accepted (2026-06-02).

Edit this page · default