Plan: Medicaid Resource/Medical Aggregation (#856, epic &63)

On this page
NOTE

Implements both halves of #856 under epic &63 (medical 2026-06-16, resources 2026-07-18), governed by ADR-034 (the orchestrator builds each program’s complete, typed determine input; per-input resolution lands in the program handler from the threaded facts, the ADR-002 black-box owning its own policy). Follows the slice-1 precedent (#619 resolve_applicant_age) and the SNAP medical-deduction precedent. Builds on the frequency-normalization foundation (#861).

Context

The Medicaid /v1/determine handler read ctx.medical_expenses_monthly.unwrap_or(ZERO) and ctx.countable_resources.unwrap_or(ZERO), but the orchestrator never sets those scalars — it threads the raw ctx.expenses / ctx.assets arrays. So on the orchestrator path the medically-needy spenddown saw $0 medical expenses and resource-tested COAs saw $0 resources. Investigation split #856 into two halves of different readiness: the medical half landed first (2026-06-16); the resource half followed (2026-07-18) once the conservative category projection resolved the false-denial blocker that had deferred it (see Status + Design).

Status

Step Description Status

1. Medical-expense aggregation helper

resolve_medical_expenses_monthly(expenses, override) in services/canopy-medicaid/src/determine.rs, mirroring the #619 resolve_applicant_age idiom — sums ctx.expenses rows whose expense_type == "medical" (the exact SNAP medical-deduction precedent), with the top-level ctx.medical_expenses_monthly kept as an override channel. monthly_amount is genuinely monthly because the orchestrator frequency-normalizes recognized frequencies before dispatch (#861).

Done (2026-06-16) — 4 unit tests (incl. a persons-wire-shape #[serde(alias="amount")] deserialization guard) + 1 integration flip test (AMN spenddown denied→approved when a "medical" row is supplied via ctx.expenses).

2. Document the recognized expense_type convention

crates/canopy-contracts-medicaid/src/determine.rsExpenseItem rustdoc now documents "medical" as the recognized convention driving MN spenddown (mirroring the canopy-tanf precedent). OpenAPI snapshot regenerated via cargo xtask api-docs --update (description only).

Done (2026-06-16).

3. Input-requirements manifest

compliance/input-requirements/medicaid.tomlmedical_expenses_monthly flips worker-factderived (gap_issue removed; the field is now satisfied in-handler from persons expenses). Gap count 38 → 37.

Done (2026-06-16).

4. countable_resources aggregation (resource half)

resolve_countable_resources(assets, override) mirroring step 1’s idiom, with a conservative category projection over the snake_case canopy_reference::AssetType vocabulary: only unambiguously-countable categories sum (bank_account, 20 CFR 416.1208); every category admitting a statutory exclusion the wire cannot express — homeplace-ambiguous real_property (20 CFR 416.1212), vehicle (one excluded, 416.1218), accessibility-dependent retirement_account (POMS SI 01120.210), the statutorily-excluded classes (IDA/ABLE/529/trust-land/self-support equity) — and any unrecognised string is omitted. Category-classification errors therefore only under-count (false-approval risk no worse than the pre-#856 $0); within-account exclusions the wire cannot flag remain with #778, correctable via the override scalar. Wire assets are now also sealed into the ADR-028 snapshot’s facts.assets. Full SSI methodology (equity valuation, first-moment-of-month, per-asset designation metadata) remains the unbound medicaid.abd.resource-counting-methodology action, triaged under #778.

Done (2026-07-18) — 5 unit tests + a proptest invariant block (bounded-by-naive-sum, excluded-category no-op, exact bank-account additivity, override-wins) + 2 integration tests (QMB resource-over-limit denial from ctx.assets with the homeplace-not-counted flip; override-beats-array).

Design — decisions

  • Aggregate in the handler, not the orchestrator or JDM (ADR-002/003). The project pattern is Rust-aggregates-then-JDM-evaluates: SNAP sums assets + filters medical expenses in its handler (canopy-snap/src/determine.rs), the Medicaid handler already sums income in-handler, and the MN-spenddown ruleset consumes only the pre-aggregated input.medical_expenses_monthly scalar. Resource-exclusion rules are Medicaid policy (ADR-002 black-box) — placing them in the orchestrator would force it to know every program’s resource-counting rules. The eventual ADR-034 map_medicaid_context refactor consumes the same pure logic.

  • Override channel preserved. ctx.medical_expenses_monthly (and ctx.countable_resources) stay as override channels for direct callers / fixtures — the same idiom as ctx.age in slice 1.

  • The resource half counts conservatively, never naïvely. A naïve sum would count primary homes (no homeplace metadata) → false denials. The shipped projection (Step 4) inverts the failure mode: a category is summed only when no statutory exclusion could apply to it as a class, so a category-classification error can only under-count — a false-approval risk no worse than the pre-#856 $0 default, and a strict improvement on it (over-limit liquid resources now correctly deny). Residual: a counted bank_account may itself hold statutorily-excluded funds the wire cannot flag (burial designation 20 CFR 416.1231, retroactive SSI/RSDI + EITC 416.1233/.1235) — within-account exclusions stay with the per-asset metadata under #778, and the override scalar is the worker’s correction path meanwhile. ADR-034 Decision 3 is satisfied: $0 now means "no countable asset facts", not "input silently defaulted". The full SSI methodology stays with the per-asset metadata it actually needs (#778).

Verification

  • cargo nextest run -p canopy-medicaid — the 4 unit helper tests + the integration flip test.

  • cargo run -p xtask — policy input-coveragemedical_expenses_monthly now satisfied (no GapIssueOnNonGap); 0 findings.

  • cargo xtask api-docs --update (after cargo xtask dev refresh) — regenerate docs/modules/ROOT/openapi/medicaid.json for the ExpenseItem rustdoc; the validate api-docs drift gate is then clean.

  • Full pre-push gate: cargo xtask validate + cargo xtask seed + cargo xtask e2e.

Edit this page · default