Plan: ADH IPV-not-established reprocesses the over-issuance as a non-fraud (IHE) claim (#981)
On this page
- Status
- Context
- Scope
- Design
- Steps
- Step 1: Appeals event publisher + program normalization (Done)
- Step 2:
overissuance_to_cents+record_decisionrefactor + store generics - Step 3: SNAP handler + subscriber + notice
- Step 4: Tests
- Step 5: e2e given-helper, journey spec, notice helper
- Step 6: Inventory + docs
- Step 7: New worker IPV/ADH UI issue + linkage
- Files Touched
- Verification
- Documentation Updates
A UI-gap unit of the Scenario Inventory & Human-Fidelity E2E (epic &61) plan (MR7’s #973–#981 backlog). Unlike the sibling UI-gap units, #981 builds a real feature first — the reclassification behavior that MR7 flagged as unauthorable ("#852’s spec is unauthorable pending a real feature") — then the service-driven journey spec that covers it. The paired human walkthrough stays blocked on a new worker IPV/ADH UI issue (no worker-portal IPV/ADH surface exists yet), matching the #972 walkthrough-blocked precedent.
Status
| Step | Description | Status |
|---|---|---|
1 |
Appeals event publisher |
Done (2026-07-07) — |
2 |
|
In progress |
3 |
SNAP |
Not started |
4 |
Tests: |
Not started |
5 |
e2e: |
Not started |
6 |
Inventory + docs: |
Not started |
7 |
New worker IPV/ADH case-UI issue filed; |
Not started |
Epic: &61
Issues: #981 (this); #852 (scenario owner — spec+behavior done here, box stays unchecked pending the walkthrough); #994 (the notice-silence gap this path deliberately avoids); new worker IPV/ADH UI issue (filed at delivery)
Branch: feature/981-adh-ihe-reclass
Context
Scenario snap.integrity.adh-not-established-reverts-claim (compliance/scenario-inventory/snap.toml:2455, tier = "journey", uncovered) asserts: when an Administrative Disqualification Hearing (ADH) finds no IPV, the related over-issuance is reprocessed as a non-fraud inadvertent-household-error (IHE) claim (7 CFR 273.16(e)(8)). Today record_decision(ipv_not_confirmed) (services/canopy-appeals/src/ipv/api.rs:326) only sets the IPV case status='cleared' — no reclassification, no event, no claim. The behavior does not exist, and there is no given-helper to drive the ADH flow, so #852’s journey spec is unauthorable.
Key finding: there is no prior claim row to reclassify — the IPV case carries only a scalar overissuance_amount: Option<Decimal> (from the referral, never read again); ClaimBasis::Ipv is defined but never constructed; the ipv.overissuance_claim_created event has no subscriber. So "reprocess as IHE" means open a new InadvertentHouseholdError claim for that amount.
#981 builds the missing behavior + the given-helper + the journey spec, flipping the row to Covered [Journey].
Owner decisions (recorded at planning):
-
Emit the 7 CFR 273.18 demand notice. The acceptance criterion says "mirror the
appeal.overpayment_assessedsubscriber" — but that path is notice-silent (the exact gap #994 tracks). The new IHE claim instead follows the recompute path (the correct template): open the claim and publishsnap.overpayment_claimed→ the overpayment demand notice. The ADH claim is not born notice-silent. -
Walkthrough blocked, not fig-leafed. The ADH flow (create-referral → schedule-ADH → send-notice → record-decision) is service-caller-only — there is no worker-portal IPV/ADH UI. So #981 ships the journey spec (service-driven → row
Covered [Journey]) and markswalkthrough_blocked_bya new "worker IPV/ADH case UI" issue (filed at delivery), matching the #972 precedent. The human walkthrough lands when that UI does.
Scope
In scope:
-
Appeals reclassification behavior:
record_decision(ipv_not_confirmed)becomes transactional and publishes a newipv.not_establishedevent when the case carried a positive over-issuance. -
A canopy-snap subscriber that opens the IHE claim in its own DB (ADR-001) and emits the demand notice.
-
A TypeScript IPV given-helper (
driveAdhNotEstablished), the journey spec, and a notice-matching e2e helper. -
The inventory binding + issue-backed
walkthrough_blocked_by, plus CHANGELOG +services.adocdoc flips.
Out of scope (routed):
-
Worker IPV/ADH case UI (create-referral / schedule-ADH / record-decision) — the new blocked-by issue; unblocks this journey’s human walkthrough.
-
The confirmed-IPV fraud-claim path (
ClaimBasis::Ipv, currently never constructed; the deadipv.overissuance_claim_createdevent) — this issue is the not-established/IHE path only. -
Retrofitting the notice onto the appeal→claim path — that is #994.
-
Claim idempotency on at-least-once redelivery —
create_claimhas no idempotency key; this is a pre-existing property shared by the appeal/recompute subscribers (not introduced here). File a follow-up if it matters; do not fold in. -
#983 (address editor) — the remaining epic-&61 UI-gap standalone.
Design
Event flow (three pieces, mirroring the appeal→claim pattern)
The reclassification is event-driven rather than an HTTP fan-out, matching the established cross-service claim-opening convention (appeal.overpayment_assessed):
worker/service ──create-referral(overissuance_amount)──▶ canopy-appeals ipv_cases row
──schedule-adh──▶ ──send-notice──▶ ──record-decision(ipv_not_confirmed)──▶
canopy-appeals: clear case + PUBLISH ipv.not_established
{ipv_case_id, household_id, person_id, program, amount_cents}
──▶ canopy-snap subscriber: create_claim(InadvertentHouseholdError, error_type="ipv_not_established")
+ PUBLISH snap.overpayment_claimed
──▶ canopy-notices ──▶ overpayment demand notice (7 CFR 273.18)
Decisions
-
record_decisionbecomes transactional (outbox). It currently runs barestate.db.inner()calls and takes noPublisher. Refactor it to a singletx+ injectExtension<Publisher>and stage the event in the same tx (mirroringimpose_disqualification,ipv/api.rs:482), so theclearedwrite + the event commit atomically. This forces store surgery:store::record_adh_decision(ipv/store.rs:136) andstore::clear_ipv_case(ipv/store.rs:202) are&PgPool-only — convert both to<'e, E: sqlx::PgExecutor<'e>>(their only caller isipv/api.rs;&PgPoolstill satisfiesPgExecutor, so nothing else breaks).get_ipv_casestays pool-based for the post-commit re-fetch. -
Guard + convert the amount in one helper.
overissuance_amountisOption<Decimal>dollars;create_claimrejects≤ 0. A pureoverissuance_to_cents(Option<Decimal>) → Option<i64>whose contract is the guard:Some(cents)only whencents > 0, elseNone— soNoneinput, a≤0amount, AND a sub-cent amount (0 < d < 0.01→ truncates to0) all yieldNone. Truncating saturating cast(d * Decimal::from(100)).to_i64().unwrap_or(i64::MAX)(idiom atservices/canopy-appeals/src/api/mod.rs:567-580) then drop≤0. The call site isif let Some(cents) = overissuance_to_cents(case.overissuance_amount) { publish… }— no separate>0check. -
Program slug is canonicalized on the wire (producer side). The referral stores
req.programverbatim (ipv/api.rs:92) and the contract doc example wrongly shows"SNAP", so a caller could store an uppercase program; the consumer keys on lowercase"snap".normalize_programlowercases at publish time; the consumer additionally compares case-insensitively (defense in depth). -
The snap subscriber opens the claim AND emits the notice. After
create_claim(ClaimBasis::InadvertentHouseholdError,error_type = "ipv_not_established",determination_id: None— IPV cases have none), it publishessnap.overpayment_claimedin the same tx, routing throughmanifest.toml:404to theoverpayment_notice. Three mechanics the named templates get subtly wrong:-
Publisher capture — the precedent is canopy-medicaid’s ELE subscribers, not the recompute HTTP handler.
boot.publisheris moved into the router Extension atsnap/src/main.rs:197, so clone it before that layer (let ipv_ihe_publisher = boot.publisher.clone();, cf.canopy-medicaid/src/main.rs:816) and re-clone per-invocation inside theFnclosure (cf. the tsnap subscriber,snap/src/main.rs:217). -
Deref —
create_claim(&mut **tx, …)(anExecutor) butpublish_overpayment_claimed(&mut *tx, …)(a&mut Transaction). -
Errors propagate —
let claim = create_claim(…)?; publish(…)?;. Do NOT copy the appeal subscriber’s log-and-Ok(())swallow (it would ack after a failed claim); propagating keeps claim + outbox atomic and retries/DLQs on failure (the recompute/medicaid pattern).
-
-
The journey is service-driven; the notice is the portal read-back. No ADH UI exists, so the spec drives the flow via a new TS IPV given-helper (service-caller token). Oracles:
pollOverpaymentClaim(the IHE claim —claim_basis == "inadvertent_household_error",error_type == "ipv_not_established",claim_amount_cents ==the over-issuance in cents) +pollNoticeMatching(the specific overpayment demand notice on the worker Notices tab, matched by subject text, not a bare count). No screenshots (walkthrough is blocked).
Wire quirks (reviewer-caught)
-
rust_decimalisserde-strworkspace-wide, sooverissuance_amount(de)serializes as a JSON string — the TS helper must send"1200", not the number1200(a number 4xx’s the referral). -
programmust be lowercase"snap"on the referral (the subscriber filter is case-sensitive at the DB layer; mirrorappeals_test.rs’s `"snap", not the contract doc’s"SNAP").
Budgets
-
B1 (route modules >500 LOC, locked 18) — edits are in
src/ipv/api.rs+src/main.rs, notservices//src/api/.rs, so B1 is unaffected. -
B2 (fns >100) — keep
record_decision≤100 (extract areprocess_not_establishedhelper if needed). -
B3a (
serde_json::Valueliteral in src, locked 745) — the snap subscriber readsenvelope.payload["…"]untyped (mirrors the appeal subscriber; no literalserde_json::Valuetext) and the appeals publisher usesserde_json::json!— neither adds to the count.
Steps
Step 1: Appeals event publisher + program normalization (Done)
Files: services/canopy-appeals/src/ipv/events.rs
normalize_program(program: &str) → String (to_ascii_lowercase) + publish_ipv_not_established(tx, publisher, ipv_case_id, household_id, person_id, program, amount_cents) building EventEnvelope::new(SOURCE, "ipv.not_established", json!{…}) with program: normalize_program(program) + amount_cents, IDs as strings (no PII per ADR-004), via publisher.publish_tx. Mirrors the sibling individual-params publishers.
Step 2: overissuance_to_cents + record_decision refactor + store generics
Files: services/canopy-appeals/src/ipv/api.rs, services/canopy-appeals/src/ipv/store.rs
-
Add pure
overissuance_to_cents(Option<Decimal>) → Option<i64>per the guard contract above; add a J7 comment justifyingunwrap_or(i64::MAX)saturation. -
Add
Extension(publisher): Extension<Publisher>torecord_decision; wrap its writes in onestate.db.inner().begin()tx (mirrorimpose_disqualification). Onipv_not_confirmed, afterclear_ipv_case+ theclearedtimeline event,if let Some(cents) = overissuance_to_cents(case.overissuance_amount), callevents::publish_ipv_not_established(&mut tx, &publisher, …)with the case’shousehold_id/person_id/program;commit. Keep the fn ≤100 LOC. -
Convert
store::record_adh_decisionandstore::clear_ipv_caseto<'e, E: sqlx::PgExecutor<'e>>.
Step 3: SNAP handler + subscriber + notice
Files: services/canopy-snap/src/ipv_claim.rs (new), services/canopy-snap/src/main.rs
Testable async fn handle_ipv_not_established(tx: &mut Transaction<'_, Postgres>, publisher: &Publisher, payload: &serde_json::Value) → anyhow::Result<()> (mirror canopy-medicaid’s testable handle_ele_case_closed):
-
Clean-skip ONLY on program mismatch —
if !payload["program"].as_str().is_some_and(|p| p.eq_ignore_ascii_case("snap")) { return Ok(()); }. -
Error (not skip) on malformed required fields — missing
person_id/household_idoramount_cents ≤ 0is a producer bug on an internal event, so returnErr→ retry/DLQ. -
create_claim(&mut **tx, …InadvertentHouseholdError, error_type="ipv_not_established", determination_id: None…)?thenevents::publish_overpayment_claimed(&mut *tx, publisher, &OverpaymentClaimedEvent{ …, overpayment_amount: format!("${}", Decimal::new(cents, 2)), claim_basis: "inadvertent_household_error" })?(derefs + error-propagation per the Design decision). -
Wiring: clone
boot.publisherbefore.layer(axum::Extension(boot.publisher))(snap/src/main.rs:197); beside theappeal.overpayment_assessedsubscription,subscribe("canopy-snap.ipv-not-established", &["ipv.not_established"], …, 5, move |envelope, tx| { let publisher = ipv_ihe_publisher.clone(); Box::pin(async move { handle_ipv_not_established(&mut *tx, &publisher, &envelope.payload).await }) }).
Step 4: Tests
Files: services/canopy-appeals/src/ipv/api.rs (+ events.rs unit), services/canopy-appeals/tests/appeals_test.rs, services/canopy-snap/src/ipv_claim.rs
-
proptest (mandatory — numeric logic) for
overissuance_to_centsover a bounded whole-cent strategycents in 1i64..=100_000_000_00; let d = Decimal::new(cents, 2)⇒overissuance_to_cents(Some(d)) == Some(cents), never panics. Plus example cases:None,"0.00","-5","0.004"→None;"1200.00"→Some(120000). -
normalize_programunit:"SNAP" → "snap". -
Appeals integration: a new
ipv_not_confirmedcase (create → schedule → send-notice → record-decision withoverissuance_amount) asserting status flips tocleared(extendipv_record_decision_sets_outcome). -
SNAP handler test (the riskiest surface): (a) valid SNAP payload → one
inadvertent_household_errorclaim + a stagedsnap.overpayment_claimedoutbox row; (b) non-SNAPprogram→ clean no-op (Ok, no claim); (c) malformed (missingperson_id/amount_cents ≤ 0) →Err.
Step 5: e2e given-helper, journey spec, notice helper
Files: tests/e2e/lib/given/ipv.ts (new), tests/e2e/lib/given/index.ts, tests/e2e/specs/journey-snap-adh-not-established-ihe-claim.spec.ts (new), tests/e2e/lib/helpers.ts
-
driveAdhNotEstablished(householdId, personId, {overissuanceAmount})mirroring theappeals_test.rssequence (NOT the staleAppealsClientdoc-comment):POST /v1/ipv/cases(all required fields —program: "snap"lowercase,overissuance_amount: String(overissuanceAmount)as a string) →PUT …/schedule-adh {adh_date: today+45d}→PUT …/send-notice {}→PUT …/record-decision {decision:"ipv_not_confirmed"}. Returnsipv_case_id+ amount;nullon devstack-down; loud throw on non-2xx. Export fromgiven/index.ts. -
Journey spec
journey-snap-adh-not-established-ihe-claim.spec.ts— mirror only the #980 skeleton’s doc-header /describe/test.skip(build()===null); DROP all screenshot scaffolding.describe:journey: an ADH that finds no IPV reprocesses the over-issuance as a non-fraud IHE claim. Oracles:pollOverpaymentClaim(householdId, {errorType:'ipv_not_established'})(basis/amount/status) +pollNoticeMatching(page, {householdId, program:'snap', textContains:'Overpayment'}). -
pollNoticeMatching(page, {householdId, program, textContains, timeoutMs?})inhelpers.ts— opens the case Notices tab (reuseopenCaseTab) and polls until atable.data-table tbody trrow’s text containstextContains; throws on timeout.
Step 6: Inventory + docs
Files: compliance/scenario-inventory/snap.toml, CHANGELOG.adoc, docs/modules/ROOT/pages/services.adoc
-
snap.tomlrow:2455—kind = "e2e-spec"(file+describeverbatim) andwalkthrough_blocked_by = ["#<new UI issue>"](no walkthrough binding). Row →Covered [Journey]. -
CHANGELOG.adoc== Unreleased / === Added: the reprocessing (appeals event + snap subscriber + demand notice), the TS helper + journey, the binding + block; note the deliberate divergence from the notice-silent appeal pattern (#994’s gap). -
services.adoc— addipv.not_establishedto the canopy-appeals Publishes list (:245) AND update the canopy-snap paragraph (~:264) to record it now subscribesipv.not_establishedand publishessnap.overpayment_claimed.
Step 7: New worker IPV/ADH UI issue + linkage
File the "worker IPV/ADH case UI" issue (type::feature/priority::medium/program::snap/service::web), get #N, use it in snap.toml’s `walkthrough_blocked_by + /relate to #981/#852. Comment on #852: spec+behavior done in #981, walkthrough blocked on #N — box stays unchecked (its DoD needs both spec AND paired walkthrough).
Files Touched
| File | Change |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
clone publisher before the Extension layer + subscribe |
|
|
|
service-driven journey spec (no screenshots) |
|
|
|
e2e-spec binding + |
|
Unreleased entry + both service paragraphs |
Verification
| Surface | Command | Asserts |
|---|---|---|
Rust unit (appeals) |
|
|
Rust unit (snap) |
|
|
Lint/fmt/budgets |
|
clean; B1=18, B2≤lock, B3a 0 new |
Journey e2e |
|
IHE claim ( |
API-docs drift |
|
no drift (new events are not OpenAPI) |
Pairing gate |
|
row → |
Documentation Updates
-
Antora canonical docs —
services.adoc(both the canopy-appeals Publishes list and the canopy-snap paragraph) -
CHANGELOG.adoc— entry under== Unreleased -
Scenario inventory —
snap.tomlbinding +walkthrough_blocked_by -
#852closing comment (box stays unchecked); new worker IPV/ADH UI issue filed + linked; epic &61 updated