T1-9 — IEVS Resolution → Worker Accept/Reject → Verified Income Write-Back (#677)
On this page
Epic &56 / Track 1, T1-9 (#677). Turn IEVS discrepancy resolution into a worker
accept/reject. A worker’s acceptance transforms the automated IEVS lead into a
human-authored verified income fact (ADR-027 §1: humans are the only authors of
record; 7 USC §2025(e): an automated match must be independently verified before
action) written into canopy-persons via the existing /claims endpoint; reject is
audited and writes no fact. The raw IEVS figure and the full "IEVS said X → worker
verified Y" reconstruction stay snap-local (the ievs_discrepancies row), so no
IEVS income figure enters the shared all-program fact store
(ADR-004 §2025(e)). Shipped in
two MRs under #677.
Status
| Step | Description | Status |
|---|---|---|
(plan) |
This execution plan + nav entry. |
Done (2026-06-20) — |
MR1 (a) snap |
|
Done (2026-06-20) — |
MR1 (b) canopy-web handlers |
|
Done (2026-06-20) — |
MR1 (c) income-tab |
|
Done (2026-06-20) — |
MR1 (d) tests + docs |
Integration (accept → |
Done (2026-06-20) — |
MR2 (a) inbox |
Enhance the existing dashboard IEVS-alerts panel deep-link to land on the income tab ( |
Done (2026-06-20) — |
MR2 (b) CLI parity |
Add |
Done (2026-06-20) — |
MR2 (c) docs (FINAL T1-9 MR) |
Antora |
Done (2026-06-20) — the docs commit. |
POST /v1/persons/{id}/income/claims already carries source + origin, and a
worker author already yields AcceptedVerified (auto_accept_status,
canopy-contracts-facts/src/lib.rs:250). The §2025(e)-restricted proposed_value
(the raw IEVS figure) is deliberately NOT routed through persons (see Decisions).
Context
IEVS discrepancy resolution
(services/canopy-snap/src/store/verification.rs:184, resolve_discrepancy) flips
a resolution_status only; an IEVS-confirmed value is never written back into the
fact corpus the orchestrator reads, so "worker resolves an IEVS hit → determination
uses the verified income" is theatre (ADR-027 Context). T1-9 closes this: accept
authors a worker-verified income fact; reject is audited.
The discrepancy data lives snap-local (ievs_discrepancies, snap migration
20260330000000): self_reported_monthly_income, verified_monthly_income,
variance_monthly, resolution_status (pending → a worker outcome),
income_type, person_id. This is the complete "IEVS said X → worker verified Y"
record, and per ADR-004 it must stay in postgres-snap (7 USC §2025(e)).
The worker-portal income editor (services/canopy-web/src/api/income.rs) is the
cross-service-writer mirror: a worker-authored /claims POST (auto-accepted
AcceptedVerified) is exactly the write an IEVS accept needs. The #632 gate helpers
shipped in T1-8 (services/canopy-web/src/api/fact_editor.rs) are reused verbatim.
Scope decision
#677 as written (and the T1-4 Slice-2 deferral note) imagined the full
"Proposed-rows-in-canopy-persons + new /claims/{fact_id}/accept|reject endpoints
a persons/security Proposed feed" model. That model collides with ADR-004 §2025(e)
— it would place un-verified IEVS income figures in the shared fact store. The
ADR-004-clean realization (confirmed with the user) keeps the same accept/reject UX
and inbox while keeping the figure snap-local:
SHIP: the accept/reject write-back path (canopy-web → existing /claims + snap
/resolve), the #632 gate (real 403), the income-tab accept/reject affordances, the
snap worker-attribution + attributed reject event, the inbox enhancement (existing
panel), CLI parity, tests, docs.
NOT BUILT (ADR-004-clean): no proposed version rows in canopy-persons, no new
persons /claims/{fact_id}/accept|reject endpoints, no persons/security Proposed
feed, no IEVS figure in persons (proposed_value stays NULL there). The raw figure
+ reconstruction stay in the snap discrepancy row; the T1-10 snapshot (assembled
inside canopy-snap) joins persons + the discrepancy for a self-contained leaf,
satisfying ADR-027 §2’s intent without the shared-store exposure.
Decisions
| Decision | Resolution |
|---|---|
ADR-004 §2025(e) tenancy |
No IEVS income figure in canopy-persons. The raw figure ( |
Persons / contracts change |
None — the existing |
Accept audit |
Reuses the T1-5 |
Reject audit |
Snap |
Worker attribution |
snap-DB |
New snap statuses |
|
Idempotency |
Accept resolves the target |
Same-type matching |
Auto-pair a discrepancy to a self-report |
Synthetic-hit defaults |
The discrepancy carries only monthly amount/type/person → defaults: |
Accept ordering |
persons |
Verified value |
Default = the IEVS amount; the worker may override the verified value (ADR-027 §2 "the value the worker verified it as") via the single visible input. |
Response / deny status |
Handlers return |
Contracts dep |
None — |
IEVS adapter |
Untouched — noop stays the default; the verification flow is unchanged (keeps the noop integration tests green). |
Dead |
The dead-status check is fixed in-MR (the feature defines the statuses); the legacy |
Implementation
Two MRs under #677 (MR1 Relates to, MR2 Closes); each commit independently
build-green; per-commit the pre-commit token gate + a fresh J1–J8 subagent over the
staged diff.
MR1 (a) snap — attribution + resolve-via-tx + reject event
crates/canopy-contracts-snap/src/verification.rs: add [serde(default)]
resolved_by_sub: Option<String> to ResolveDiscrepancyRequest + relax resolved_by
to [serde(default)] Option<PersonId> (no current caller sends a meaningful worker
PersonId); add resolved_by_sub to IevsDiscrepancyRow / IevsDiscrepancy /
From<Row>. New migration services/canopy-snap/migrations/20260620000000_add_
discrepancy_resolved_by_sub.sql (later than the latest 20260518004851): ALTER
TABLE ievs_discrepancies ADD COLUMN IF NOT EXISTS resolved_by_sub TEXT (nullable;
ADR-016 expand-step). Change store::verification::resolve_discrepancy to take
&mut Transaction + resolved_by_sub: Option<&str>. New events.rs helper
publish_ievs_discrepancy_resolved staging
EventEnvelope::new(SOURCE, "ievs.discrepancy_resolved", json!{ "author":
{"author_type":"worker","sub":…}, "discrepancy_id", "application_id", "person_id",
"resolution_status", "income_type", "program":"snap" }) — no dollar amount. The
handler (api/verification_handler.rs) gains Extension<Publisher>, opens a tx,
flips + stages + commits; valid_statuses gains accepted_verified + rejected.
A unit test asserts the event payload carries no restricted fields (mirror
abawd_event_payload_has_no_restricted_fields).
MR1 (b) canopy-web handlers — api/ievs.rs
New services/canopy-web/src/api/ievs.rs (mod ievs; in api/mod.rs), mirroring
income.rs and reusing the fact_editor helpers. Two POST routes
/actions/ievs/accept + /actions/ievs/reject. AcceptDiscrepancyForm
(household_id, person_id, program, discrepancy_id, income_type, verified_amount
[visible, default = the IEVS amount, worker-overridable], frequency [hidden, default
monthly], effective_date [hidden, default today via
chrono::Utc::now().date_naive()], employer_name, income_id [matched fact_id or
empty], target_section); RejectDiscrepancyForm (… + resolution_notes). A sibling
body builder mirrors income_claim_body with source:"ievs"
origin:"ievs:{discrepancy_id}" (no proposed_value).
accept_discrepancy: deny_unless_in_scope(&worker, &form.program,
&form.household_id)? → with_service_identity → resolve the target fact_id
idempotently (matched income_id; else an existing income fact in /full with
provenance.origin == "ievs:{discrepancy_id}"; else new — origin is on the /full
wire but ProvenanceView doesn’t parse it today, so read the raw Value or extend
ProvenanceView) → POST /v1/persons/{id}/income/claims → then
clients.snap.put /v1/verification/discrepancies/{id}/resolve (accepted_verified,
resolved_by_sub = worker.worker_id) → redirect_to_case. reject_discrepancy:
gate → clients.snap.put /resolve (rejected, notes, resolved_by_sub) →
redirect; no persons write. Ordering = persons-first (the verified fact is the
load-bearing determination input; the origin-based fact resolution makes a retry
idempotent).
MR1 (c) income-tab affordances + dead-status fix
render_income_tab (case_detail.rs:1953): add discrepancy_id (from d["id"])
ievs_verified_raw (from d["verified_monthly_income"]) to IncomeRow (and every
construction site + test fixture — the T1-8 fan-out lesson), populated in both merge
branches. Auto-pair a discrepancy to a self-report income_id only when exactly one
same-type self-report exists; multiple → accept-to-new-fact (income_id empty) + a
note. Change the dead resolution_status == "resolved" check (:2030/:2089): open
(is_disc) only while pending/absent; accepted_verified → is_verified (✓
Verified pill); rejected → is_rejected (neutral pill); only PENDING discrepancies
synthesize an unreported-hit row. templates/cases/tab_income.html: on discrepancy
rows ({% if !income.discrepancy_id.is_empty() %}) an Accept <details> form
(hidden _csrf/context/income_id/frequency/effective_date, one visible
verified_amount defaulting to {{ income.ievs_verified_raw }}) + a Reject form
(optional resolution_notes).
MR1 (d) tests + docs
Integration (devstack, mirror services/canopy-persons/tests/fact_version_writes_
test.rs): accept-new-fact → AcceptedVerified + claim_source='ievs'
origin='ievs:{id}' + appears in /v1/households/{id}/full, with proposed_value
NULL (no figure in persons); accept-as-correction supersedes the self-report;
double-accept of an unreported hit → exactly one income fact (idempotent); snap
resolve flips status + records resolved_by_sub, reject stages
ievs.discrepancy_resolved with author.sub + no figure + no income_versions
row; a 403 gate-deny on an out-of-scope worker (both handlers). Render units
(case_detail.rs): the accept/reject forms wire on a discrepancy row; an
accepted_verified row shows ✓ Verified not Discrepancy; ambiguous same-type →
accept-as-new-fact. E2E tests/e2e/specs/worker-ievs-resolution.spec.ts (mirror
worker-fact-editors.spec.ts + gotoWorkerCaseSection; the noop adapter yields a
discrepancy for the high-wages SSN-suffix band 30–49; test.skip if the seed
exposes none). Docs: api/canopy-web.adoc (new /actions/ievs/* + cross-service
path + 403), api/canopy-snap.adoc (resolved_by_sub + the event); this plan’s
MR1 cells → Done; CHANGELOG.adoc == Unreleased; master-plan T1-9 row →
In progress.
MR2 (a) inbox enhancement
services/canopy-web/src/dashboard/panels/ievs_alerts.rs + its template: change the
per-row deep-link to /cases/{household_id}?program=snap&focus_section=income so the
worker lands on the income tab where MR1’s accept/reject forms live. Panel
source/producer unchanged (noop IEVS tests stay green). Update the panel render test
for the new query.
MR2 (b) CLI parity (ADR-007)
Add a snap_url to the CLI profile config (tools/canopy-cli/src/config.rs — it
has no snap URL today). New tools/canopy-cli/src/cmd/verification.rs + a
Verification subcommand in main.rs: list-discrepancies (snap GET), accept
(composes persons /claims source:ievs + snap /resolve accepted_verified),
reject (snap /resolve rejected). ApiClient::put already exists. A CLI accept
+ reject roundtrip test.
MR2 (c) docs (FINAL T1-9 MR)
Antora api pages; this plan Status → Done + an As-built note (the ADR-004-clean
realization, the partial-failure bounded limitation, the resolved_by_sub ADR-019
bridge); master-plan T1-9 row → Done (YYYY-MM-DD) — <sha> + reconcile its Design
note (accept = worker-verified version source=ievs + origin, the figure stays
snap-local; reject = snap flip + attributed event; noop adapter untouched);
CHANGELOG Changed entry. No .claude/CLAUDE.md change (only the final MR of the
epic flips its status tables; T1-9 is not the last epic unit). Close #677 with the
mandatory closing comment.
Verification
Per commit: cargo build + cargo clippy -p <crate> --all-targets — -D warnings
targeted nextest. After handler/template work: cargo xtask dev refresh then
cargo nextest run -p canopy-web -p canopy-persons -p canopy-snap (devstack-gated;
confirm new tests RAN) + cargo xtask e2e. Before push: full cargo xtask validate.
Load-bearing assertions: the 403 gate-deny (both handlers); accept →
AcceptedVerified + claim_source=ievs + origin + appears in /full with
proposed_value NULL; accept-correction supersedes the self-report; double-accept
of an unreported hit → exactly one fact (idempotent); reject → no persons row + snap
flipped + an author.sub-attributed event with no figure; the global IEVS_ADAPTER
is untouched (noop tests green). cargo xtask quality-budgets: B3a_src/B5 may
rise (the new ievs.rs json! bodies + the /full origin-lookup Value reads) —
bump the lock with a documented justification + surface it (ADR-030 gate), never a
silent --write-lock.
Residual security risk (state in the MR): the BFF program gate proves the worker
holds some claim for form.program, not that the write is bound to this
household’s program — true binding needs the epic &52 / #424 server-side
X-Canopy-Actor enforcement. T1-9 matches the existing BFF-gate posture.
As-built notes
-
Shipped in two MRs: MR1 (!646, merged
6bf9e3a5) — the accept/reject write-back path (snap attribution + event, canopy-web handlers, income-tab affordances, tests, docs); MR2 (this branch) — the inbox deep-link + CLI parity. -
ADR-004-clean realization (refined from the user’s initial choice): only the worker-VERIFIED value enters canopy-persons, tagged
source=ievs+origin; the raw IEVS figure (proposed_value) is never written there (structural —IncomeClaimRequesthas no such field +deny_unknown_fields+ the store hardcodesNone). The figure + the "IEVS said X → worker verified Y" reconstruction stay in the snapievs_discrepanciesrow; the T1-10 snapshot (snap-assembled) joins them. No canopy-persons/contracts change was needed — the existing/claimsalready carriessource+origin. -
Bounded limitations (documented, not buried): (1) partial-failure — accept is persons-first; a persons-OK/snap-fail leaves the discrepancy open (re-actionable), and the origin-based fact resolution makes the retry idempotent (never a duplicate, never a resolved-discrepancy-with-no-fact). A
resolved_fact_idhardening is #876. (2) attribution —resolved_by_subis the ADR-019 on-behalf-of bridge until first-class actor plumbing lands (#874). (3) ambiguous same-type — a discrepancy matching >1 same-type self-report renders as accept-to-new-fact; per-row worker selection is #875. -
Deviation — the CLI test is a
list-discrepanciespositive test, not a full accept/reject roundtrip. IEVS discrepancies are produced by the verification flow (there is no create-discrepancy endpoint), so a CLI roundtrip needs a seeded discrepancy; the accept/reject WRITE semantics are covered at the canopy-persons integration layer (source=ievs,proposed_valueNULL, correction supersedes)
the snap event unit tests, and the browser wiring by the Playwright spec. -
Two latent bugs handled per the workflow rule: the dead
resolution_status == "resolved"check was fixed in-MR (the feature defines the statuses); the legacyactions::resolve_discrepancyPOST→PUT + missing-attribution defects were filed separately (#872), not folded in. -
Incidental: MR1 forward-synced
migration-runbook.adocto template v2026.12 (pre-existing drift; unblocked the check-docs gate). -
Follow-ups filed + related to #677: #872, #874, #875, #876, #877.
Follow-ups
-
fix:issue (related to #677): the legacyactions::resolve_discrepancy(+actions_tanf::resolve_discrepancy_tanf) (a).post()`s a PUT route (a 405 swallowed by the 200-on-error quirk) AND (b) sends no `resolved_by/resolved_by_sub(no attribution). -
feat:issue (related to #677): ADR-019 on-behalf-of plumbing soresolved_by(and the T1-5 persons-close author) carries a first-class actor identity, retiring theresolved_by_subbridge. -
feat:issue (related to #677): dashboard IEVS-alerts panel → snap-sourced actionable resolution counts (deeper than MR2’s deep-link enhancement) + explicit per-row worker selection when multiple same-type incomes exist. -
chore:issue (optional, related to #677): aresolved_fact_idcolumn onievs_discrepanciesset on accept, as a stronger idempotency key than MR1’s origin-based fact resolution + a clean discrepancy↔fact link. -
chore:issue (optional): a canopy-security namedparse_event_typearm forievs.discrepancy_resolved(a clean action/resource label; the#wildcard
author.subalready attribute it). -
Plan: Worker Fact Authoring and Provenance — server-side #632 enforcement at the persons REST boundary (
X-Canopy-Actor) = epic &52 / #424.