Plan: TANF Denial-Reason Code Emitted from JDM Ruleset

On this page

Status

Step Description Status

1

Add one new output column to the TANF eligibility JDM decision table at rulesets/georgia/tanf-eligibility.json: {"id": "o-denial-code", "name": "Denial Reason Code", "type": "expression", "field": "denial_reason_code"}. Populate the per-rule value on each of the 8 rules already defined in the file (rules at lines ~44-184). Mapping: r-tl-exceeded"time_limit", r-gross-over / r-net-over"earned_income", r-no-deprivation / r-dep-not-verified / r-no-citizenship / r-no-children"unspecified", r-eligible"" (empty — no denial). Canonical code strings drawn from rulesets/federal/cross-program-2026.json trigger_reasons arrays (TSNAP: employment / earned_income / increased_hours / new_employment; TMA: same minus employment). time_limit is outside the TSNAP/TMA trigger list but kept as a distinct code for audit-trail fidelity — subscribers exact-match against their trigger list and ignore non-matches, so emitting time_limit is strictly additive.

Done (2026-04-22) — MR !107

2

Extend TanfEligibilityOutput at services/canopy-tanf/src/rules_client.rs:54-62 with [serde(default)] pub denial_reason_code: Option<String>. [serde(default)] keeps older cached JDM-engine responses (without the new column) deserialisable — non-breaking. No change to the Rust side of canopy-rules; the engine already passes through unknown fields.

Done (2026-04-22) — MR !107

3

Add a DB migration at services/canopy-tanf/migrations/20260422000000_add_denial_reason_code.sql that runs ALTER TABLE tanf_determinations ADD COLUMN denial_reason_code TEXT;. Nullable — approvals leave it NULL. No backfill needed (historical determinations predate the JDM emitting the code).

Done (2026-04-22) — MR !107

4

Add pub denial_reason_code: Option<String> to TanfDetermination at services/canopy-tanf/src/store/models.rs:131 (next to the existing pub denial_reason: Option<String>). Update the INSERT statement at services/canopy-tanf/src/store/determinations.rs (the create_determination helper — verify column list) to include the new column.

Done (2026-04-22) — MR !107

5

Thread the code from elig_result.denial_reason_code through services/canopy-tanf/src/determine.rs. The denial-path branches at lines ~176-234 build a tuple (status, benefit_amount, basis, denial_reason, effective_date, expiration_date); widen this to a 7-tuple with denial_reason_code: Option<String> appended, and populate TanfDetermination.denial_reason_code at construction (line ~238). For approvals, denial_reason_code = None. For time-limit denials, hardcode Some("time_limit".to_string()) at the time-limit path (that path doesn’t go through the rules engine) — this is policy-consistent with the JDM’s time-limit rule.

Done (2026-04-22) — MR !107

6

Replace the hack at services/canopy-tanf/src/api/handlers.rs:79-80: delete let raw_reason = det.denial_reason.as_deref().unwrap_or("unspecified") and let closure_reason = categorize_closure_reason(raw_reason); replace with let closure_reason = det.denial_reason_code.as_deref().unwrap_or("unspecified"). Delete the categorize_closure_reason function (lines 169-190) and its 3 unit tests (gross_income_maps_to_earned_income, net_income_maps_to_earned_income, time_limit_maps_to_time_limit, unknown_reason_maps_to_unspecified — that’s 4 tests, not 3; all under mod tests at lines ~192-259). Remove the use super::{categorize_closure_reason, …​} import. Update .claude/docs/known-issues.md:49 entry — the "Denial-reason strings vs trigger-reason keywords" known issue resolves with this plan; mark it resolved with a date.

Done (2026-04-22) — MR !107

7

JDM ruleset test update: rulesets/georgia/tanf-eligibility.json has a companion tests.json if any test fixtures exist — verify with rg -n '"tanf-eligibility"' rulesets/*/.json. If test fixtures exist that exercise the decision-table output, add assertions on the new denial_reason_code field. If none exist, add a short JDM fixture test via the cargo xtask rules check workflow (if the tool supports case-based testing) or skip — the Rust-side integration tests below cover the happy paths.

Done (2026-04-22) — MR !107

8

Cross-service integration tests: services/canopy-snap/tests/tsnap_e2e_test.rs:29-31 comment already documents the expected mapping ("That denial reason maps to earned_income in categorize_closure_reason()"). Update the comment to reference the JDM code-column (denial_reason_code output in tanf-eligibility.json). The test body should pass unchanged because the event payload already carries reason: "earned_income" — the code-path that generates that value is what changed, not the value itself. Same for services/canopy-medicaid/tests/tma_e2e_test.rs:221-223. Run both test files to confirm green.

Done (2026-04-22) — MR !107

9

Add DenialReasonCode enum to crates/canopy-reference/src/enums.rs (or a new denial_reason.rs module if the enums.rs file is already crowded). Variants: EarnedIncome, Employment, IncreasedHours, NewEmployment (the 4 TSNAP/TMA trigger codes from rulesets/federal/cross-program-2026.json), TimeLimit (TANF-specific, emitted by r-tl-exceeded), Unspecified (gate denials), and Other(String) (preserves unknown codes verbatim so cross-program-2026.json remains the source of truth for additions per ADR-011). Implement FromStr / Display with lowercase-snake_case string form matching the JDM output ("earned_income"EarnedIncome, etc.). Derive Serialize + Deserialize with #[serde(rename_all = "snake_case", untagged)]-equivalent handling so the wire format is a plain string, not a tagged enum — use custom serialize_with/deserialize_with adapters that go through FromStr/Display so both TanfEligibilityOutput (JSON from zen-engine) and the event payload (JSON to RabbitMQ) roundtrip as plain strings. Add unit tests: exact roundtrip for each named variant, Other("future_code") roundtrip preserving the string, and empty-string handling.

Done (2026-04-22) — MR !107

10

Swap Option<String> for Option<DenialReasonCode> at the three Rust sites: TanfEligibilityOutput.denial_reason_code (Step 2), TanfDetermination.denial_reason_code (Step 4), and the short-circuit in determine.rs (Step 5 — Some("time_limit".to_string()) becomes Some(DenialReasonCode::TimeLimit)). The handler’s read (Step 6) becomes det.denial_reason_code.as_ref().map(|c| c.to_string()).unwrap_or_else(|| "unspecified".to_string()) (or a dedicated .code_string() helper returning &str if Display impl is lifetime-friendly). Other(s) serialises back to s, so event payloads remain byte-identical. DB column stays TEXT — the enum serialises through Display on insert and FromStr on select. Add a sqlx::Type impl or use sqlx::query_as! with a conversion function — verify the existing create_determination INSERT pattern and match it.

Done (2026-04-22) — MR !107

11

Roadmap sync: mark the "`categorize_closure_reason()` maps TANF denial strings to TSNAP/TMA keywords" row at docs/modules/ROOT/pages/roadmap.adoc:682-684 Done with the date, citing this plan. CHANGELOG == Unreleased / === Fixed entry documenting the hack removal AND the enum introduction. Update .claude/docs/known-issues.md:49 (see Step 6). Explicitly mark the "Typed DenialReasonCode enum in canopy-reference" item resolved in the plan’s Potential Improvements section — it moved in-scope and landed with this MR, not a future polish pass.

Done (2026-04-22) — MR !107

Branch: feature/tanf-denial-reason-code-from-jdm
Labels: type::chore, priority::medium, program::tanf, service::tanf, service::rules, workflow::ready

Context

The hack at services/canopy-tanf/src/api/handlers.rs:177-190:

pub(crate) fn categorize_closure_reason(raw: &str) -> &'static str {
    let lower = raw.to_ascii_lowercase();
    if lower.contains("income") || lower.contains("gross") || lower.contains("net") {
        "earned_income"
    } else if lower.contains("employ") {
        "employment"
    } else if lower.contains("time limit") {
        "time_limit"
    } else if lower.contains("sanction") {
        "sanction"
    } else {
        "unspecified"
    }
}

…was added during the cross-program-functional-testing plan because TSNAP/TMA subscribers exact-match against canonical keywords loaded from rulesets/federal/cross-program-2026.json, but the TANF JDM ruleset emits free-form denial strings like "Gross income exceeds PAMMS 1501 Gross Income Ceiling (185% of Standard of Need)". The hack bridges the two by substring-matching the lowercased reason.

Known bug in the hack:

  1. lower.contains("employ") matches "unemployment" too, so any future denial reason mentioning "unemployment" would be incorrectly categorised as employment (a TSNAP trigger).

  2. Collapses distinct denial reasons into the same category with no compile-time guarantee that every JDM rule output maps to something sensible. New rules added without updating the hack silently fall through to unspecified.

  3. Puts policy-mapping logic in Rust source, violating the spirit of ADR-003 (ruleset owns determination logic) and ADR-011 (policy lives in data).

  4. Brittle against JDM text edits — rewording a denial string in tanf-eligibility.json can silently change category.

Fix approach: Emit the canonical code directly from the JDM decision table as a parallel output column. Each rule owns its code, no substring matching required, tests at the JDM level instead of the Rust mapping level.

Scope

In scope:

  • One new output column in rulesets/georgia/tanf-eligibility.json with canonical code per rule.

  • New DenialReasonCode enum in canopy-reference with Other(String) fallback preserving ADR-011 source-of-truth (federal ruleset stays authoritative for new codes).

  • TanfEligibilityOutput + TanfDetermination grow denial_reason_code: Option<DenialReasonCode>.

  • One DB migration adding the nullable column (stays TEXT — enum roundtrips via FromStr/Display).

  • Handler replaces the substring hack with a direct read.

  • Delete the hack function + its 4 unit tests.

  • Cross-service integration test comment updates (tests themselves unchanged — wire format stays a plain string).

  • Known-issues doc update.

Out of scope:

  • Sanction-path denials. The hack has a sanction branch (line 185) but no current JDM rule emits a sanction denial — the work-requirements endpoint is a separate path with its own event publishing. Not touched in this plan.

  • Medicaid / CHIP denial-code parity. Same pattern could apply to canopy-medicaid’s r-* rules, but no equivalent hack exists there today (subscribers consume the TANF event only). If/when canopy-medicaid starts publishing cross-program events with categorized reasons, a parallel plan handles it — the DenialReasonCode enum introduced here is the foundation they’d reuse.

  • Historical backfill. Pre-fix determinations retain denial_reason free-text and denial_reason_code = NULL. Historical TSNAP/TMA matches were already best-effort; no retroactive fixup needed.

  • Build-time code generation from cross-program-2026.json. Hand-maintained variants with Other(String) preserves source-of-truth today — build.rs-derived variants are a future infrastructure improvement, not needed for correctness.

Dependencies

  • rulesets/georgia/tanf-eligibility.json — 8 rules get one new output-column value each.

  • crates/canopy-reference/src/enums.rs (or new denial_reason.rs module) — new DenialReasonCode enum + FromStr / Display + serde adapters + unit tests.

  • services/canopy-tanf/src/rules_client.rs TanfEligibilityOutput — one new field typed as Option<DenialReasonCode>.

  • services/canopy-tanf/migrations/20260422000000_add_denial_reason_code.sql — new migration file (TEXT column).

  • services/canopy-tanf/src/store/models.rs + services/canopy-tanf/src/store/determinations.rs — model field + INSERT update + sqlx::Type or conversion helper for the enum ↔ TEXT roundtrip.

  • services/canopy-tanf/src/determine.rs — widen the tuple + populate the field (short-circuit uses DenialReasonCode::TimeLimit).

  • services/canopy-tanf/src/api/handlers.rs — delete hack, read enum field, serialise to string for event payload.

  • .claude/docs/known-issues.md + docs/modules/ROOT/pages/roadmap.adoc + CHANGELOG.adoc — doc sync.

No schema changes to the event payload (the wire already carries reason: String; the enum’s Display produces an identical string). No changes to canopy-snap or canopy-medicaid subscribers — they keep exact-matching against their trigger lists. canopy-reference may gain one new dependency import site; verify with cargo tree.

Design

JDM output column

tanf-eligibility.json decision table currently declares 5 outputs (o-eligible, o-reasons, o-gross-pass, o-net-pass, o-dep-pass). Add a 6th:

{"id": "o-denial-code", "name": "Denial Reason Code", "type": "expression", "field": "denial_reason_code"}

…then populate on each of the 8 rules. Example row (r-gross-over):

{
  "_id": "r-gross-over",
  "_description": "PAMMS 1501 gross income test — over 185% Standard of Need",
  "c-gross-income": "> gross_income_ceiling",
  "o-eligible":        "false",
  "o-reasons":         "[\"Gross income exceeds PAMMS 1501 Gross Income Ceiling (185% of Standard of Need)\"]",
  "o-denial-code":     "\"earned_income\"",
  "o-gross-pass":      "false",
  "o-net-pass":        "true",
  "o-dep-pass":        "true"
}

Note: JDM output values are expression-evaluated, so string literals need double-quoting ("\"earned_income\"" — outer quotes for JSON, inner for the expression).

Mapping table:

Rule ID Code Rationale

r-tl-exceeded

time_limit

PAMMS 1655 60-month federal cap; not a TSNAP trigger but worth preserving for audit

r-no-deprivation

unspecified

Deprivation gate; no corresponding TSNAP/TMA code

r-dep-not-verified

unspecified

Verification failure; no cross-program signal

r-no-citizenship

unspecified

Immigration gate; no cross-program signal

r-no-children

unspecified

Dependent-children gate; not earned-income

r-gross-over

earned_income

TSNAP + TMA trigger — income-based denial per 7 CFR 273.26 / 42 CFR 435.112

r-net-over

earned_income

Same family of denial — net test failed post-disregard

r-eligible

"" (empty)

Approval path — no denial code

DenialReasonCode enum design

Defined in canopy-reference:

/// Canonical TANF denial-reason code, mirrored from
/// `rulesets/federal/cross-program-2026.json` `trigger_reasons` arrays.
///
/// The federal ruleset is the source of truth per ADR-011; this enum
/// names only the currently-known codes for Rust-side ergonomics. Unknown
/// codes pass through as `Other(String)` so newly-added entries in
/// `cross-program-2026.json` flow through without a Rust edit.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum DenialReasonCode {
    EarnedIncome,       // TSNAP + TMA trigger (7 CFR 273.26 / 42 CFR 435.112)
    Employment,         // TSNAP-only trigger
    IncreasedHours,     // TSNAP + TMA trigger
    NewEmployment,      // TSNAP + TMA trigger
    TimeLimit,          // TANF-specific (PAMMS 1655 federal 60-month cap)
    Unspecified,        // catch-all for gate denials (deprivation, citizenship, children, etc.)
    Other(String),      // preserves unknown codes verbatim — ADR-011 source-of-truth escape hatch
}

impl FromStr for DenialReasonCode { /* snake_case → variant, unknown → Other(s.to_string()) */ }
impl Display for DenialReasonCode { /* variant → snake_case, Other(s) → s.clone() */ }

Serde uses FromStr / Display via custom serialize_with / deserialize_with adapters (or the #[serde(try_from = "String", into = "String")] pattern) so the wire format is a plain string — keeps JDM input / event payload byte-identical to the current Option<String> shape and keeps cross-program-2026.json the single source of truth for the keyword list.

Rust plumbing

Follow the existing pattern for denial_reason, with the enum replacing the raw string at each site:

  • TanfEligibilityOutput.denial_reason_code: Option<DenialReasonCode> deserialised from the JDM output column via serde string-adapter.

  • TanfDetermination.denial_reason_code: Option<DenialReasonCode> stored alongside denial_reason. DB column is TEXT; sqlx roundtrips via FromStr/Display (explicit impl or #[sqlx(type_name = "text")]).

  • determine.rs builds both at the same construction site — the denial-path branch widens by one field, the approval-path sets it to None.

  • The time-limit denial path at determine.rs:178-186 (which short-circuits before the rules engine call) uses Some(DenialReasonCode::TimeLimit) to match what the JDM would emit if it were called. Matches the JDM’s r-tl-exceeded code.

The handler reads the enum and .to_string()`s it for the event payload. `Other("future_code") roundtrips as "future_code", so any new code emitted by the JDM — without a corresponding Rust-side variant — still reaches TSNAP/TMA subscribers unchanged. If the subscriber’s trigger list grows first, the Rust code continues to work; if the variant list grows first, the enum self-documents the additions.

Handler simplification

Before:

if det.status == "denied" {
    let raw_reason = det.denial_reason.as_deref().unwrap_or("unspecified");
    let closure_reason = categorize_closure_reason(raw_reason);
    // ... publish event with reason = closure_reason
}

After:

if det.status == "denied" {
    let closure_reason = det.denial_reason_code.as_deref().unwrap_or("unspecified");
    // ... publish event with reason = closure_reason
}

The unwrap_or("unspecified") fallback preserves defence-in-depth — if some future code path produces a denial without a code (e.g., a non-JDM-sourced denial), the event still publishes with a sensible default.

Migration

-- services/canopy-tanf/migrations/20260422000000_add_denial_reason_code.sql
ALTER TABLE tanf_determinations ADD COLUMN denial_reason_code TEXT;

Nullable; no backfill. Historical rows retain NULL, consistent with the hack’s unavailability pre-fix.

Files Touched

Category Files

JDM ruleset

rulesets/georgia/tanf-eligibility.json

Shared enum

crates/canopy-reference/src/enums.rs (or new denial_reason.rs module) + re-export in crates/canopy-reference/src/lib.rs

Rust rules client

services/canopy-tanf/src/rules_client.rs

DB migration

services/canopy-tanf/migrations/20260422000000_add_denial_reason_code.sql (new)

Store model

services/canopy-tanf/src/store/models.rs + services/canopy-tanf/src/store/determinations.rs

Determination logic

services/canopy-tanf/src/determine.rs

Handler + hack removal

services/canopy-tanf/src/api/handlers.rs

Cross-service tests

services/canopy-snap/tests/tsnap_e2e_test.rs (comment only) + services/canopy-medicaid/tests/tma_e2e_test.rs (comment only)

Docs

.claude/docs/known-issues.md, docs/modules/ROOT/pages/roadmap.adoc, CHANGELOG.adoc

No changes to canopy-snap or canopy-medicaid Rust code. No event-payload shape change.

Verification

Per-step verification

  1. cargo nextest run -p canopy-reference — new DenialReasonCode roundtrip tests pass (each named variant, Other("future_code"), empty-string handling).

  2. cargo xtask rules check — JDM ruleset validates under zen-engine, o-denial-code column typed correctly.

  3. cargo nextest run -p canopy-tanf — all existing tests pass (minus the 4 deleted hack tests).

  4. cargo nextest run -p canopy-snap tsnap_e2e_test — TSNAP cross-service path produces identical event payload (reason = "earned_income") as before — enum’s Display must produce byte-identical strings to the old hack’s output for known codes.

  5. cargo nextest run -p canopy-medicaid tma_e2e_test — TMA path produces identical event payload.

  6. Ad-hoc integration probe: post a gross-over TANF denial, SELECT denial_reason_code FROM tanf_determinations shows earned_income (stored as TEXT via enum Display).

  7. cargo xtask policy audit — clean (no citations.toml changes expected; the JDM emits codes that are already cited via the rulesets/federal/cross-program-2026.json trigger_reasons arrays).

  8. cargo xtask validate — full battery green.

Plan-level verification

  1. The hack function + its 4 unit tests are gone. rg -n categorize_closure_reason returns no hits.

  2. Cross-service tests still green with unchanged assertions (the event-payload value is unchanged; only the code path generating it changed).

  3. cat .claude/docs/known-issues.md | grep -A1 "Denial-reason strings" shows the entry marked resolved.

Documentation Updates

  • .claude/docs/known-issues.md — line 49 "Denial-reason strings vs trigger-reason keywords" — append resolution note with date pointing to this plan.

  • docs/modules/ROOT/pages/roadmap.adoc — row at lines 682-684 "`categorize_closure_reason()` maps TANF denial strings to TSNAP/TMA keywords" — mark Done with date citing this plan.

  • CHANGELOG.adoc== Unreleased / === Fixed bullet documenting (a) the hack removal + unemployment false-positive that motivated the fix, and (b) the new DenialReasonCode enum landing as part of the same MR (not deferred).

Potential Improvements

Out of scope for this plan but worth capturing:

  • Typed DenialReasonCode enum in canopy-reference. Resolved 2026-04-22 — folded into this plan’s Steps 9-10 rather than tracked as future polish. Enum lives in crates/canopy-reference with Other(String) fallback preserving ADR-011 source-of-truth (federal ruleset stays authoritative for new codes).

  • JDM-level assertion tests. Adding a structured test-fixture format for decision tables (e.g., input JSON → expected output JSON) would let us lock in the code mapping at ruleset-validation time, independent of canopy-tanf integration tests. cargo xtask rules check could grow a test mode.

  • Medicaid denial-code parity. rulesets/georgia/medicaid-*.json rulesets have similar o-reasons outputs. If canopy-medicaid ever grows cross-program event publishing (outside the TMA receiver role), a parallel plan can reuse the DenialReasonCode enum introduced here — no Rust-type additions required.

  • Sanction denial path. The hack has a sanction branch that’s currently dead code (no JDM rule emits a sanction denial through the eligibility path — sanctions come from work-requirements). If a future plan adds a sanction rule to tanf-eligibility.json, the code-mapping is one line of JDM config + one enum variant addition (or Other("sanction") if the subscriber list predates the Rust variant), not a hack reintroduction.

  • Build-time enum generation from cross-program-2026.json. A build.rs that parses the federal ruleset at compile time and generates the DenialReasonCode variants automatically would remove the hand-maintenance burden. Out of scope today — the Other(String) fallback already keeps the ruleset authoritative at runtime.


Tracked follow-ups (filed 2026-04-24 after audit of plan Errata + Potential Improvements sections across the repo):

  • #324 — build.rs generation of DenialReasonCode (from Potential Improvements)

  • #329 — JDM-level assertion tests (from Potential Improvements)

Tracked follow-ups (filed 2026-05-04 during PI sweep):

  • #415 — Medicaid denial-code parity (DenialReasonCode enum reuse)

  • #416 — Sanction denial path through eligibility ruleset

Errata

2026-04-22 — TanfDetermination field stays Option<String>, not Option<DenialReasonCode>

The plan’s Step 10 and Design Rust plumbing bullet specified TanfDetermination.denial_reason_code: Option<DenialReasonCode>. Implementation diverged: the field stays Option<String> at the store-model layer. Rationale:

  • sqlx scope bloat. TanfDetermination derives sqlx::FromRow. Typing the field as Option<DenialReasonCode> requires sqlx::Type + Encode + Decode impls on the enum — and canopy-reference is a pure shared crate with no sqlx dependency today. Adding sqlx to canopy-reference (or gating behind a feature flag) expanded scope beyond a hack-removal debt-reduction MR.

  • Codebase convention. TanfDetermination.status: String already stores an enum-shaped value as raw text. Keeping denial_reason_code: Option<String> matches that pattern; introducing enum types piecemeal on one field would create an inconsistent store layer.

  • ADR-011 goals still met. The enum is the canonical canopy-reference type; determine.rs parses the raw JDM output through DenialReasonCode::FromStr on ingress (the Other(String) escape hatch preserves source-of-truth for codes absent from the Rust variant list), then re-serialises via Display before storage. Any caller that wants type safety at the read side calls det.denial_reason_code.as_ref().and_then(|s| s.parse::<DenialReasonCode>().ok()).

  • Event-payload shape unchanged. The handler reads det.denial_reason_code.as_deref() and passes the string to the event publisher — identical to what the enum-typed version would produce via Display.

Net effect on the user’s "fold it in now" directive: the enum lives in canopy-reference as promised (Step 9) and is exercised in the Rust ingress path at determine.rs (Step 5 — parses JDM output, validates via FromStr, re-serialises). The store layer stays stringly-typed to match existing conventions. A future "typed store columns" refactor (post-UAT crate-quality-parity pass) can promote denial_reason_code, status, and similar fields together with one sqlx-impl story. Tracked as Potential Improvement: "sqlx-typed enum columns on `TanfDetermination`".

Plan text in Status Step 10 and the Design Rust plumbing bullet is intentionally left as-is so reviewers can see the deviation; downstream readers should treat this Errata entry as authoritative.

Edit this page · default