ADR-027: Worker Fact Authoring, Provenance, and Valid-Time Versioning
On this page
Amends
ADRs are immutable once accepted, so this ADR amends ADR-001 rather than editing it. Read both together: ADR-001 establishes that each service owns its own database and that cross-service entities are referenced by bare UUID; this ADR defines how eligibility facts enter the fact store — who may author them, how automated sources propose changes, how every change is attributed, and how prior values are preserved — so that the inputs to a determination are attributable, correctable, and reproducible.
Delivery tracks
This ADR defines the full target model. The implementation plan delivers it in two tracks, and this ADR marks per-property which track owns it:
-
Track 1 — SNAP-UAT-minimum: a worker can author SNAP facts (income/assets/expenses) and accept/reject an IEVS claim, versioned and attributed, feeding a determination that snapshots its inputs. The history is attributable and reconstructable (not yet cryptographically tamper-evident).
-
Track 2 — post-UAT correctness: the remaining fact types and programs, cryptographic hardening of the change history (ADR-014 chain extension), record-redaction/expungement, materiality→recert, appeals/overpayment consumers, and the FTI-bearing program work.
The demo video is decoupled from both tracks (it ships as an honest seeded/applicant-authored walk).
Context
A data-flow trace during the Plan 4 demo build (the worker SNAP→TANF→ELE determination walk, #654) established that the worker portal cannot author the facts a determination reads. The eligibility orchestrator assembles its determination context exclusively from canopy-persons (fetch_household_context, services/canopy-eligibility/src/orchestrator.rs:82-219), yet the write surface into canopy-persons is almost empty:
-
The applicant-portal finalize path (
services/canopy-applications/src/api/mod.rs:569-787) is the only path that creates persons, households, members, and income. It cannot write assets or expenses — thePersonsClientexposes only the four write methodscreate_person/create_household/add_member/add_income(services/canopy-applications/src/persons_client.rs:77-145). -
The worker income editor (
services/canopy-web/src/api/income.rs:64-208) is the only worker-portal write into canopy-persons. No worker path adds a household member, edits a person, or adds/edits an asset or expense (the case-detail persons/assets/expenses sections are#562stubs). -
Worker intake "sections" save to
application_sections.payloadJSONB (services/canopy-applications/src/store/sections.rs:166-187), read only by canopy-web for rendering. No code reads section payload into a determination — so "worker fills intake → run determination" is theatre. -
IEVS discrepancy resolution (
services/canopy-snap/src/store/verification.rs:184-205) flips a status only; an IEVS-confirmed value is never written back into canopy-persons.
Caseworkers learn facts from interviews, walk-ins, phone calls, mailed verifications, and life events. That information cannot be sourced from the applicant or an automated interface alone. The worker portal must be able to author and correct facts.
Two legal constraints shape how:
-
Automated matches are leads, not authority. IEVS data (7 USC §2025(e)) must be independently verified before action; an automated interface must never silently overwrite a worker- or applicant-asserted fact.
-
Every change must be attributable and reconstructable. Today this is impossible: canopy-persons mutates facts in place —
income::update(services/canopy-persons/src/store/income.rs:41-74) overwrites the row — and income/asset/expense mutations emit no events at all (services/canopy-persons/src/events.rspublishes onlyperson.created/updatedandhousehold.*, IDs-only).
The codebase contains no claim, proposal, provenance, or bitemporal model today. This ADR establishes one.
Decision
1. Humans are the only authors of record; automated sources only propose
Every eligibility fact carries a typed author:
pub enum Author {
Worker(KeycloakSub), // a human caseworker's OIDC subject
Applicant(HouseholdRef), // the applicant acting on their own case (see §8)
System, // migrations/backfills ONLY — never fact content
}
Worker and Applicant are the only authors of fact content; System is for migrations/backfills and must never author a substantive eligibility fact. Automated sources (IEVS, SAVE, FDSH, SSA) are not authors — they are claim sources whose output a human must accept (§2). Edit authorization reuses the existing per-worker program-scope gate (#632; enforced today by the worker.in_program_scope(…) checks in services/canopy-web/src/api/applications.rs, e.g. :839, :1247, :1803, :1903).
2. Everything is a claim; a fact is an accepted claim
All fact writes flow through one generic claim envelope plus per-source adapters. A claim is an assertion-with-provenance; a fact is an accepted claim. Auto-accept differs by source:
| Claim source | Auto-accept | Resulting fact status |
|---|---|---|
Applicant (self-report) |
yes |
|
Worker |
yes — the worker is the reviewer |
|
Automated (IEVS/SAVE/FDSH/SSA) |
no |
|
Conflict and read semantics (correctness-critical). A proposed claim that overlaps an already-accepted fact coexists with it — it does not supersede the accepted fact. Acceptance is what supersedes. The fact read path that feeds a determination MUST filter claim_status IN ('accepted_unverified','accepted_verified'), so a proposed or rejected version can never reach a live determination — without this filter an unaccepted IEVS lead would leak into a verdict, violating "match is a lead, not authority". A rejection writes no fact version but emits its own attributed event (who rejected which proposed claim, when).
Accepting an automated claim is non-lossy. When a worker accepts an IEVS lead, the new accepted version records both the value the worker verified it as and the original proposed value + proposing source in its provenance (the proposed claim row is retained, not discarded), so a determination snapshot’s leaf is self-contained for the "IEVS said X → worker verified Y" reconstruction (ADR-028) without depending on canopy-security.
The per-source adapters are additive; the existing verification_responses shape (crates/canopy-contracts-verification) is folded into this model. Where a proposed claim awaits a worker, it surfaces in the existing pending-verifications / IEVS-alerts dashboard panel and the case-detail verifications section (not a new bespoke queue).
3. Valid-time versioning of time-varying facts; identity facts are correction-only
Time-varying facts (income, household composition, address, employment) are valid-time-versioned: effective-dated, append-only. Each write appends a version; the engine reads the version true as of a date. This replaces today’s overwrite-in-place (income::update).
The "current facts as of date D" read is: claim_status IN ('accepted_*') AND superseded_at IS NULL AND valid_from ⇐ D AND (valid_to IS NULL OR valid_to > D).
The append invariant + the correction algorithm. For each fact_id, the current (superseded_at IS NULL, accepted) versions MUST partition the valid-time line without overlap. A correction therefore runs in one transaction that (a) supersedes every current version whose valid-time it overlaps and (b) re-inserts the unaffected sub-ranges of those superseded versions as new current versions. The non-overlap invariant is DB-enforced with a btree_gist exclusion constraint on (fact_id, daterange(valid_from, valid_to)) WHERE superseded_at IS NULL AND claim_status LIKE 'accepted%'. Concurrent appends to one fact_id are serialized (advisory lock or the exclusion constraint itself), so a lost update / two-current-versions state is impossible. fact_id on backfill = the existing row id for income/assets/expenses; the household-member and address identity rules are specified in the plan.
Identity facts (DOB, SSN) are not valid-time-versioned — an apparent change is a correction of the record on the transaction-time axis (§4, §8), never a new valid-time version.
4. Bitemporal split: canopy-persons holds latest valid-time; canopy-security records the change history
canopy-persons is the system of record for current valid-time facts. The transaction-time change history (who/when/old→new + every accept/reject) is recorded in canopy-security, carried there by attributed events on the bus.
-
Attribution rides in the event payload. Today neither the
EventEnvelope(crates/canopy-mq/src/envelope.rs:20-40— only id/timestamp/source_service/event_type/payload/trace_context) nor the persons events carry actor/before/after. The new{income,asset,expense,household_member}.{claimed,accepted,rejected}events carry typedauthor,claim_source,before,after, and the fact/household/person identity in the typed payload (the envelope is not extended). Income/asset/expense changes emit nothing today; that gap closes. -
Integrity posture (Track 1 = attributable; Track 2 = tamper-evident). In Track 1 the change history is attributable and reconstructable — every fact mutation is attributed and recorded. It is not claimed cryptographically tamper-evident yet: canopy-security’s ADR-014 chain hashes only
previous_hash || event_id || event_type || timestamp(services/canopy-security/src/store/mod.rs:26-38), which does not cover the actor or the before/after metadata. Integrity of the inputs that actually drove a verdict is instead carried by the signature-bound determination snapshot (ADR-028). Extending the ADR-014 chain hash to cover actor + a before/after content-hash — making the general change history tamper-evident — is a Track 2 ADR-014 amendment, required where TANF/Medicaid FTI makes it mandatory. -
A scoped change-history query endpoint (
GET /v1/security/household/{id}/{resource}, caseworker-scoped) returns the transaction-time history. The UI may show only the latest value; the endpoint must exist for appeals and QC. canopy-security stays an append-only ledger, not a correctness-path read — determinations do not query it to be correct (ADR-028 freezes their inputs).
Failure modes. Fact writes are durable locally in canopy-persons and audited via the ADR-018 outbox — i.e. fail-open on audit latency: a fact write succeeds and the attributed event is delivered asynchronously. This is acceptable in Track 1 (the determination snapshot, not the live audit ledger, is the integrity-of-record); Track 2’s chain hardening tightens it where FTI demands fail-closed (cf. ADR-014’s 503-on-breach posture). When canopy-persons is unreachable at determination time, the orchestrator refuses rather than determines on absent facts (it does not silently degrade — cf. ADR-005).
5. The fact-write endpoint is policy-aware (two verbs, same endpoint)
A fact write routes its regulatory consequences:
-
A reported change — a customer-reported change in circumstance during an active certification period (SNAP 7 CFR 273.12) — appends a forward-effective version and fires change-reporting: a
snap_change_reportsrecord, the 10-day timeliness clock, and an adjustment re-determination for the rest of the cert period. -
Everything else — initial entry, a worker correction of record, pre/post-cert updates — appends the version without change-reporting. (A correction to an already-determined past period still triggers an overpayment recalculation.)
Any benefit-affecting outcome of a reported change or correction (adjustment re-determination, overpayment) MUST emit the corresponding Notice of Action with appeal rights via canopy-notices (ADR-010; NoticeType already carries ChangeInCircumstancesNotice / OverpaymentNotice / ContinuedBenefitsNotice). The materiality→recert wiring (§6) and the notice wiring are Track 2.
6. A reported change runs a rules-driven materiality check that nudges a recertification
A change through an authorized mechanism during a cert period runs a materiality check: a dry-run re-determination — a non-persisting "what-if" mode that does not write a determination row, does not write a snapshot, and does not emit notices/events — computed against the same ruleset corpus version the frozen snapshot recorded (so the diff isolates the fact change, not a since-shipped policy update), compared to the frozen snapshot’s verdict/amount. If material, raise a worker-actioned recertification nudge (never auto). This reuses the canopy-renewals recert flow but requires net-new wiring: a renewals→eligibility call path and the orchestrator dry-run mode (canopy-renewals does not call eligibility today). Track 2.
7. Facts are tied to people; programs enrich, they do not duplicate
Per ADR-001, every fact references a person by UUID. Generic cross-program facts live in canopy-persons; program-specific accruals (TANF time-limit months, ABAWD clocks, sanctions) stay program-owned, person_id-keyed, no person duplication — as tanf_time_limits / tanf_work_requirements do today. Programs remain stateless evaluators (ADR-002). There is no first-class case aggregate — a "case" is a view over a household, its applications, and its programs.
8. Record correction, redaction, and the append-only tension
Append-only facts + an immutable change history + immutable determination snapshots collide with the operational and legal need to purge erroneous or expunged PII (a wrongly-entered SSN; a record expungement). The resolution, by track:
-
Track 1 (now): a correction writes a corrected current version (the value model already supports this). Raw identity values (SSN) are kept out of event before/after payloads so an error does not propagate into the immutable history. This is a named, bounded limitation: the erroneous value still persists in canopy-persons (encrypted at rest as
ssn_encrypted) and in any snapshot that read it, and a true purge is not yet possible. -
Track 2: a genuine purge via crypto-shredding — per-value encryption where redaction destroys that value’s key, leaving the hash over ciphertext intact so the chain stays verifiable — across facts, events, and snapshots, with an explicit Pub 1075 access-audit story for SSN.
9. Applicant authorship and the ADR-026 privacy boundary
Author::Applicant(HouseholdRef) must not breach ADR-026 (opaque sessions, no applicant JWT). The HouseholdRef recorded as author is the household_id resolved at finalize (the same id the worker portal already uses), not the opaque portal session handle; applicant-authored fact events carry no portal-session identity and obey ADR-004 event-bus scrubbing (§4). This is an explicit reconciliation with ADR-026’s identity surface.
10. CLI/API/UI parity
Every new write or read endpoint (claim ingest, accept/reject, as-of reads, assets/expenses mutation, the change-history endpoint, IEVS accept/reject) ships a corresponding canopy CLI subcommand in the same or immediately following MR (ADR-007); the existing income/asset CLI commands are updated to the versioned/claim semantics. The plan’s Files-Touched and per-MR acceptance carry the CLI rows.
Consequences
-
canopy-persons gains an append-only, bitemporal fact model with the non-overlap invariant, the correction algorithm, and per-
fact_idserialization. Forward-only expand-contract migrations (ADR-016) reshape the time-varying tables; an explicit dual-write window keeps the income editor and orchestrator working across the cutover. -
A new shared
canopy-contracts-factscrate definesClaim,Author, and provenance types. -
canopy-persons emits attributed events for every fact mutation and accept/reject (attribution in the typed payload); the finalize fan-out is batched into a bounded number of events to respect ADR-018’s per-publish cost and the single-writer audit chain (ADR-014). Event volume is sized in the plan.
-
The worker portal grows real fact-authoring UI (replacing the
#562stubs and the disconnected intake-sections path); IEVS resolution becomes a worker-accepted claim with verified write-back. -
Every new endpoint enforces the
#632gate and ships CLI parity. -
This is a multi-quarter, multi-service epic split across two tracks; Track 1 (SNAP) is the SNAP-UAT-relevant subset, Track 2 the correctness remainder. The demo is decoupled from both.
Alternatives considered
Alternative 1: Route worker intake-section JSONB into the determination. Rejected — it entrenches a second, untyped, unversioned, unattributed fact store with no provenance or history. Facts belong in canopy-persons, versioned and attributed.
Alternative 2: Let automated sources write facts directly, with audit. Rejected — violates the legal requirement that a human view and approve before an automated match changes a fact (7 USC §2025(e)).
Alternative 3: Single transaction-time history only (no valid-time). Rejected — cannot answer "what was true as of the determination date" after a later correction, which appeals and overpayment recalculation require.
Alternative 4: Store the full change history in canopy-persons (no canopy-security split). Rejected — duplicates the ledger machinery ADR-014 already provides and bloats the fact store’s hot read path. canopy-persons answers "now / as-of"; canopy-security holds "who/when/old→new".
Amendment 1 — T2-7 materiality dry-run, as-built (#680, 2026-06-25)
Status unchanged (still Accepted). This records how §5/§6’s materiality→recert path is realized by T2-7 (#680) and the design decisions taken. ADRs are immutable once accepted, so this is an in-document amendment, not an edit to the Decision.
-
Trigger = the fact-change event, not the change-report endpoint. §6 says "a change through an authorized mechanism during a cert period runs a materiality check." The authoritative trigger is the fact write, which lands in program-agnostic canopy-persons (§7) — so the SNAP-specific reaction is decoupled via the event bus (ADR-004): canopy-renewals subscribes to the persons
income/asset/expense/member.claimedevents (T1-5) and runs the check when the changed person’s household has an active certification. §5 (change-reporting, #868) and §6 (this) are independent consumers of the same event — neither depends on the other. The renewals metadata change-report endpoint is a separate concern, untouched. -
Frozen policy is the COMPLETE bundle, not just the corpus. §6 pins "the same ruleset corpus version the frozen snapshot recorded." Corpus alone is insufficient: the pay-period conversion factors and the self-employment-deduction settings also drive the verdict (injected before the main ruleset). T2-7 enriches the snapshot’s resolved policy parameters (see ADR-028 Amendment 3) to the full verdict-affecting bundle and the dry-run re-injects all of it; the corpus pin threads through all three rules calls (self-employment, alien, main).
-
The dry-run is unsigned and truly write-free. Beyond "does not write a determination row/snapshot/notice/event" (§6), the dry-run’s rules-engine calls run in an ephemeral
?audit=falsemode so norule_evaluations/outbox rows are written either. The result is an unsigned outcome (not a determination of record; ADR-002 signing attaches to persisted determinations only). The baseline it compares against is the determination-of-record’s verdict + benefit (plaintext on the determination row). -
Evaluated as-of the change’s effective date. The dry-run reads facts as-of the triggering version’s
valid_from(so a forward-effective change is captured at authoring time), under the frozen policy. Retroactive corrections (valid_from< baselineas_of) are a named exclusion (the correction/overlay follow-up). -
Materiality threshold is operational. "Material" = a verdict flip OR a benefit delta
>= [snap.materiality] benefit_delta_threshold_cents. The dollar amount is a Canopy operational/product decision (7 CFR 273.12(a)(1)(ii) sets the state-defined significant-change standard; the amount is not federal), pending Georgia SME confirmation (#921) — distinct from the PAMMS-3035[snap.verification_thresholds]verification triggers. -
Worker-actioned recert nudge + change-in-circumstances notice (MR5/MR6). A material change records one
recert_nudgesrow keyed by a unique(certification_id, source_event_id)— so at-least-once event delivery + worker retries yield exactly one nudge per material fact-change event — and emitsrenewal.material_change(carryinghousehold_id+person_id), which canopy-notices routes to a new informationalChangeInCircumstancesNotice(non-adverse, no fair-hearing rights — the later recert determination issues its own NOA). The nudge is never automatic: it surfaces in the case-detail renewals tab (andcanopy renewals nudge list), where a worker files or dismisses it (canopy renewals nudge action); filing records the decision (action_taken=filed_recert) under anis_material AND action_taken IS NULLguard (a re-action is a no-op), and provisioning the recert application is a tracked follow-up. -
Pre-T2-7 backfill boundary. Only corpus versions current at a boot after T2-7 ships are replayable, and only post-T2-7 snapshots carry the full policy bundle; an older baseline degrades to a typed "cannot isolate → manual review", never a wrong verdict. Acceptable pre-1.0 (devstack reseeds).
See the T2-7 plan.