Plan: ADH IPV-not-established reprocesses the over-issuance as a non-fraud (IHE) claim (#981)

On this page
NOTE

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 publish_ipv_not_established + producer-side normalize_program (lowercase-canonical wire slug)

Done (2026-07-07) — services/canopy-appeals/src/ipv/events.rs

2

overissuance_to_cents guard-helper + record_decision tx/Publisher refactor + record_adh_decision/clear_ipv_case store generics

In progress

3

SNAP handle_ipv_not_established handler (new ipv_claim.rs) + subscriber wiring + snap.overpayment_claimed notice emission

Not started

4

Tests: overissuance_to_cents proptest + unit cases; normalize_program unit; appeals ipv_not_confirmed→cleared integration; SNAP handler test (valid / non-SNAP / malformed)

Not started

5

e2e: given/ipv.ts driveAdhNotEstablished + journey spec + pollNoticeMatching helper

Not started

6

Inventory + docs: snap.toml binding + walkthrough_blocked_by; CHANGELOG.adoc; services.adoc (both paragraphs)

Not started

7

New worker IPV/ADH case-UI issue filed; walkthrough_blocked_by + /relate to #981/#852

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_assessed subscriber" — 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 publish snap.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 marks walkthrough_blocked_by a 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 new ipv.not_established event 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.adoc doc 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 dead ipv.overissuance_claim_created event) — 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 redeliverycreate_claim has 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_decision becomes transactional (outbox). It currently runs bare state.db.inner() calls and takes no Publisher. Refactor it to a single tx + inject Extension<Publisher> and stage the event in the same tx (mirroring impose_disqualification, ipv/api.rs:482), so the cleared write + the event commit atomically. This forces store surgery: store::record_adh_decision (ipv/store.rs:136) and store::clear_ipv_case (ipv/store.rs:202) are &PgPool-only — convert both to <'e, E: sqlx::PgExecutor<'e>> (their only caller is ipv/api.rs; &PgPool still satisfies PgExecutor, so nothing else breaks). get_ipv_case stays pool-based for the post-commit re-fetch.

  • Guard + convert the amount in one helper. overissuance_amount is Option<Decimal> dollars; create_claim rejects ≤ 0. A pure overissuance_to_cents(Option<Decimal>) → Option<i64> whose contract is the guard: Some(cents) only when cents > 0, else None — so None input, a ≤0 amount, AND a sub-cent amount (0 < d < 0.01 → truncates to 0) all yield None. Truncating saturating cast (d * Decimal::from(100)).to_i64().unwrap_or(i64::MAX) (idiom at services/canopy-appeals/src/api/mod.rs:567-580) then drop ≤0. The call site is if let Some(cents) = overissuance_to_cents(case.overissuance_amount) { publish… } — no separate >0 check.

  • Program slug is canonicalized on the wire (producer side). The referral stores req.program verbatim (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_program lowercases 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 publishes snap.overpayment_claimed in the same tx, routing through manifest.toml:404 to the overpayment_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.publisher is moved into the router Extension at snap/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 the Fn closure (cf. the tsnap subscriber, snap/src/main.rs:217).

    • Derefcreate_claim(&mut **tx, …) (an Executor) but publish_overpayment_claimed(&mut *tx, …) (a &mut Transaction).

    • Errors propagatelet 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_decimal is serde-str workspace-wide, so overissuance_amount (de)serializes as a JSON string — the TS helper must send "1200", not the number 1200 (a number 4xx’s the referral).

  • program must be lowercase "snap" on the referral (the subscriber filter is case-sensitive at the DB layer; mirror appeals_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, not services//src/api/.rs, so B1 is unaffected.

  • B2 (fns >100) — keep record_decision ≤100 (extract a reprocess_not_established helper if needed).

  • B3a (serde_json::Value literal in src, locked 745) — the snap subscriber reads envelope.payload["…"] untyped (mirrors the appeal subscriber; no literal serde_json::Value text) and the appeals publisher uses serde_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 justifying unwrap_or(i64::MAX) saturation.

  • Add Extension(publisher): Extension<Publisher> to record_decision; wrap its writes in one state.db.inner().begin() tx (mirror impose_disqualification). On ipv_not_confirmed, after clear_ipv_case + the cleared timeline event, if let Some(cents) = overissuance_to_cents(case.overissuance_amount), call events::publish_ipv_not_established(&mut tx, &publisher, …) with the case’s household_id/person_id/program; commit. Keep the fn ≤100 LOC.

  • Convert store::record_adh_decision and store::clear_ipv_case to <'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 mismatchif !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_id or amount_cents ≤ 0 is a producer bug on an internal event, so return Err → retry/DLQ.

  • create_claim(&mut **tx, …InadvertentHouseholdError, error_type="ipv_not_established", determination_id: None…)? then events::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.publisher before .layer(axum::Extension(boot.publisher)) (snap/src/main.rs:197); beside the appeal.overpayment_assessed subscription, 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_cents over a bounded whole-cent strategy cents 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_program unit: "SNAP" → "snap".

  • Appeals integration: a new ipv_not_confirmed case (create → schedule → send-notice → record-decision with overissuance_amount) asserting status flips to cleared (extend ipv_record_decision_sets_outcome).

  • SNAP handler test (the riskiest surface): (a) valid SNAP payload → one inadvertent_household_error claim + a staged snap.overpayment_claimed outbox row; (b) non-SNAP program → clean no-op (Ok, no claim); (c) malformed (missing person_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 the appeals_test.rs sequence (NOT the stale AppealsClient doc-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"}. Returns ipv_case_id + amount; null on devstack-down; loud throw on non-2xx. Export from given/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?}) in helpers.ts — opens the case Notices tab (reuse openCaseTab) and polls until a table.data-table tbody tr row’s text contains textContains; throws on timeout.

Step 6: Inventory + docs

Files: compliance/scenario-inventory/snap.toml, CHANGELOG.adoc, docs/modules/ROOT/pages/services.adoc

  • snap.toml row :2455 kind = "e2e-spec" (file + describe verbatim) and walkthrough_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 — add ipv.not_established to the canopy-appeals Publishes list (:245) AND update the canopy-snap paragraph (~:264) to record it now subscribes ipv.not_established and publishes snap.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

services/canopy-appeals/src/ipv/events.rs

normalize_program + publish_ipv_not_established (Step 1)

services/canopy-appeals/src/ipv/api.rs

overissuance_to_cents + tx/Publisher record_decision refactor + proptest/unit tests

services/canopy-appeals/src/ipv/store.rs

record_adh_decision / clear_ipv_casePgExecutor generics

services/canopy-appeals/tests/appeals_test.rs

ipv_not_confirmedcleared integration test

services/canopy-snap/src/ipv_claim.rs (new)

handle_ipv_not_established + handler tests

services/canopy-snap/src/main.rs

clone publisher before the Extension layer + subscribe ipv.not_established

tests/e2e/lib/given/ipv.ts (new), given/index.ts

driveAdhNotEstablished + export

tests/e2e/specs/journey-snap-adh-not-established-ihe-claim.spec.ts (new)

service-driven journey spec (no screenshots)

tests/e2e/lib/helpers.ts

pollNoticeMatching

compliance/scenario-inventory/snap.toml

e2e-spec binding + walkthrough_blocked_by

CHANGELOG.adoc, docs/modules/ROOT/pages/services.adoc

Unreleased entry + both service paragraphs

Verification

Surface Command Asserts

Rust unit (appeals)

cargo nextest run -p canopy-appeals

overissuance_to_cents cases; ipv_not_confirmed clears the case

Rust unit (snap)

cargo nextest run -p canopy-snap

handle_ipv_not_established: valid SNAP → one IHE claim + staged snap.overpayment_claimed; non-SNAP → clean no-op; malformed → Err

Lint/fmt/budgets

cargo clippy --all-targets + --profile test; cargo fmt --check --all; cargo xtask quality-budgets

clean; B1=18, B2≤lock, B3a 0 new

Journey e2e

cargo xtask e2e --devstack-profile full — specs/journey-snap-adh-not-established-ihe-claim.spec.ts --project journey

IHE claim (inadvertent_household_error, error_type=ipv_not_established, amount == over-issuance, open) + a new demand notice on the Notices tab

API-docs drift

cargo xtask api-docs (devstack up)

no drift (new events are not OpenAPI)

Pairing gate

cargo xtask scenarios audit

row → Covered [Journey] with issue-backed walkthrough_blocked_by; no MissingWalkthrough / OrphanSpec

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.toml binding + walkthrough_blocked_by

  • #852 closing comment (box stays unchecked); new worker IPV/ADH UI issue filed + linked; epic &61 updated

Edit this page · default