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) — a618c22.

MR1 (a) snap

canopy-snapresolve_discrepancy records the worker’s keycloak sub (resolved_by_sub column + DTO), runs in a transaction, and stages an attributed ievs.discrepancy_resolved event (payload carries an author.sub object — the shape canopy-security parses — IDs/status/actor only, no dollar figure, ADR-004 §2025(e)); valid_statuses gains accepted_verified + rejected.

Done (2026-06-20) — 14420ff9.

MR1 (b) canopy-web handlers

canopy-web api/ievs.rs (new) — accept_discrepancy (POSTs a worker-authored /claims income fact, source=ievs + origin=ievs:{id}, then flips the snap discrepancy accepted_verified) and reject_discrepancy (snap /resolve rejected, no persons write). #632 gate (real 403) + idempotent fact_id resolution + persons-first ordering. Routes /actions/ievs/{accept,reject}.

Done (2026-06-20) — f3e8d5c.

MR1 (c) income-tab

render_income_tabIncomeRow gains discrepancy_id + ievs_verified_raw; fix the dead resolution_status == "resolved" check (recognize accepted_verified/rejected); only PENDING discrepancies synthesize an unreported-hit row; same-type-ambiguity safety. tab_income.html gains per-discrepancy Accept + Reject forms.

Done (2026-06-20) — f3e8d5c.

MR1 (d) tests + docs

Integration (accept → AcceptedVerified + claim_source=ievs + no figure in proposed_value + feeds determination; accept-as-correction; idempotent double-accept; snap resolve + attributed reject event; 403 gate), render units, an E2E spec; Antora api/canopy-web.adoc + api/canopy-snap.adoc; CHANGELOG; master-plan T1-9 row → In progress.

Done (2026-06-20) — f3e8d5c (tests) + the docs commit; budget lock 25a9cdd.

MR2 (a) inbox

Enhance the existing dashboard IEVS-alerts panel deep-link to land on the income tab (?program=snap&focus_section=income) where the accept/reject forms live — no new feed; panel source unchanged (noop IEVS tests stay green).

Done (2026-06-20) — dd1f2f64.

MR2 (b) CLI parity

Add snap_url to the CLI profile config; new canopy verification {list-discrepancies, accept, reject} (ADR-007) composing the persons /claims + snap /resolve calls.

Done (2026-06-20) — dd1f2f64.

MR2 (c) docs (FINAL T1-9 MR)

Antora api pages; this plan → Done + As-built; master-plan T1-9 row → Done + reconcile; CHANGELOG; follow-up issues filed; close #677.

Done (2026-06-20) — the docs commit.

NOTE
T1-9 needs no canopy-persons / canopy-contracts change — the existing 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 (proposed_value) + the reconstruction stay snap-local; on accept only the worker-verified value (a human-authored fact) enters persons via the existing /claims, tagged source=ievs + origin=ievs:{discrepancy_id} (verification-method metadata, no figure). The guarantee is structural, not BFF-convention: IncomeClaimRequest has no proposed_value field and #[serde(deny_unknown_fields)] (a client that sent one → 422), and the store hardcodes proposed_value: None — so the figure cannot reach persons via /claims even by an errant caller.

Persons / contracts change

None — the existing /claims already carries source + origin; a worker author already yields AcceptedVerified.

Accept audit

Reuses the T1-5 income.claimed event (claim_source=ievs + author.sub — canopy-security already indexes it by fact_id + author.sub); no new income.accepted event (no-dead-code).

Reject audit

Snap /resolve (rejected) stages an ievs.discrepancy_resolved event whose payload carries an author:{author_type:worker,sub} object (the shape canopy-security/src/event_parsing.rs extracts) — IDs/status/actor, no figure — and writes no persons fact (ADR-027 §2).

Worker attribution

snap-DB resolved_by_sub TEXT (audit) = worker.worker_id; the event carries author.sub; legacy resolved_by UUID relaxed to nullable (the ADR-019 on-behalf-of bridge; same gap as the T1-5 close author:None).

New snap statuses

accepted_verified + rejected added to valid_statuses (and recognized in the income-tab merge, fixing the dead "resolved" check).

Idempotency

Accept resolves the target fact_id idempotently: matched self-report → its fact_id; else reuse an existing income fact with origin=ievs:{discrepancy_id} (from /full); else new. A persons-OK/snap-fail retry corrects the same fact — never a duplicate — with no new column or contract.

Same-type matching

Auto-pair a discrepancy to a self-report income_id only when exactly one same-type self-report exists; multiple → accept-to-new-fact + a note (worker selection is a follow-up).

Synthetic-hit defaults

The discrepancy carries only monthly amount/type/person → defaults: frequency="monthly", effective_date=today, employer=None.

Accept ordering

persons /claims first, then snap /resolve — the verified fact (the determination input) is the load-bearing write; persons-OK/snap-fail leaves the discrepancy open/re-actionable + idempotent-on-retry, never a resolved-discrepancy-with-no-fact (the dangerous inverse).

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 Result<Redirect, (StatusCode, Html<String>)> (the T1-8 shape); a #632 deny is a real 403 via deny_unless_in_scope.

Contracts dep

None — serde_json::json! bodies + serde_json::Value reads (mirror income’s no-dep edge pattern).

IEVS adapter

Untouched — noop stays the default; the verification flow is unchanged (keeps the noop integration tests green).

Dead "resolved" vs legacy bugs

The dead-status check is fixed in-MR (the feature defines the statuses); the legacy actions::resolve_discrepancy POST→PUT + missing-attribution defects → one fix: follow-up (out of scope; the new handlers are correct from the start).

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/claimsthen 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_verifiedis_verified (✓ Verified pill); rejectedis_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 — IncomeClaimRequest has no such field + deny_unknown_fields + the store hardcodes None). The figure + the "IEVS said X → worker verified Y" reconstruction stay in the snap ievs_discrepancies row; the T1-10 snapshot (snap-assembled) joins them. No canopy-persons/contracts change was needed — the existing /claims already carries source+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_id hardening is #876. (2) attributionresolved_by_sub is 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-discrepancies positive 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_value NULL, 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 legacy actions::resolve_discrepancy POST→PUT + missing-attribution defects were filed separately (#872), not folded in.

  • Incidental: MR1 forward-synced migration-runbook.adoc to 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 legacy actions::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 so resolved_by (and the T1-5 persons-close author) carries a first-class actor identity, retiring the resolved_by_sub bridge.

  • 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): a resolved_fact_id column on ievs_discrepancies set 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 named parse_event_type arm for ievs.discrepancy_resolved (a clean action/resource label; the # wildcard
    author.sub already attribute it).

  • Plan: Worker Fact Authoring and Provenance — server-side #632 enforcement at the persons REST boundary (X-Canopy-Actor) = epic &52 / #424.

Edit this page · default