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 |
Done (2026-04-22) — MR !107 |
2 |
Extend |
Done (2026-04-22) — MR !107 |
3 |
Add a DB migration at |
Done (2026-04-22) — MR !107 |
4 |
Add |
Done (2026-04-22) — MR !107 |
5 |
Thread the code from |
Done (2026-04-22) — MR !107 |
6 |
Replace the hack at |
Done (2026-04-22) — MR !107 |
7 |
JDM ruleset test update: |
Done (2026-04-22) — MR !107 |
8 |
Cross-service integration tests: |
Done (2026-04-22) — MR !107 |
9 |
Add |
Done (2026-04-22) — MR !107 |
10 |
Swap |
Done (2026-04-22) — MR !107 |
11 |
Roadmap sync: mark the "`categorize_closure_reason()` maps TANF denial strings to TSNAP/TMA keywords" row at |
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:
-
lower.contains("employ")matches "unemployment" too, so any future denial reason mentioning "unemployment" would be incorrectly categorised asemployment(a TSNAP trigger). -
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. -
Puts policy-mapping logic in Rust source, violating the spirit of ADR-003 (ruleset owns determination logic) and ADR-011 (policy lives in data).
-
Brittle against JDM text edits — rewording a denial string in
tanf-eligibility.jsoncan 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.jsonwith canonical code per rule. -
New
DenialReasonCodeenum incanopy-referencewithOther(String)fallback preserving ADR-011 source-of-truth (federal ruleset stays authoritative for new codes). -
TanfEligibilityOutput+TanfDeterminationgrowdenial_reason_code: Option<DenialReasonCode>. -
One DB migration adding the nullable column (stays
TEXT— enum roundtrips viaFromStr/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
sanctionbranch (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 — theDenialReasonCodeenum introduced here is the foundation they’d reuse. -
Historical backfill. Pre-fix determinations retain
denial_reasonfree-text anddenial_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 withOther(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 newdenial_reason.rsmodule) — newDenialReasonCodeenum +FromStr/Display+ serde adapters + unit tests. -
services/canopy-tanf/src/rules_client.rsTanfEligibilityOutput— one new field typed asOption<DenialReasonCode>. -
services/canopy-tanf/migrations/20260422000000_add_denial_reason_code.sql— new migration file (TEXTcolumn). -
services/canopy-tanf/src/store/models.rs+services/canopy-tanf/src/store/determinations.rs— model field + INSERT update +sqlx::Typeor conversion helper for the enum ↔TEXTroundtrip. -
services/canopy-tanf/src/determine.rs— widen the tuple + populate the field (short-circuit usesDenialReasonCode::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 |
|---|---|---|
|
|
PAMMS 1655 60-month federal cap; not a TSNAP trigger but worth preserving for audit |
|
|
Deprivation gate; no corresponding TSNAP/TMA code |
|
|
Verification failure; no cross-program signal |
|
|
Immigration gate; no cross-program signal |
|
|
Dependent-children gate; not earned-income |
|
|
TSNAP + TMA trigger — income-based denial per 7 CFR 273.26 / 42 CFR 435.112 |
|
|
Same family of denial — net test failed post-disregard |
|
|
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 alongsidedenial_reason. DB column isTEXT;sqlxroundtrips viaFromStr/Display(explicit impl or#[sqlx(type_name = "text")]). -
determine.rsbuilds both at the same construction site — the denial-path branch widens by one field, the approval-path sets it toNone. -
The time-limit denial path at
determine.rs:178-186(which short-circuits before the rules engine call) usesSome(DenialReasonCode::TimeLimit)to match what the JDM would emit if it were called. Matches the JDM’sr-tl-exceededcode.
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.
Files Touched
| Category | Files |
|---|---|
JDM ruleset |
|
Shared enum |
|
Rust rules client |
|
DB migration |
|
Store model |
|
Determination logic |
|
Handler + hack removal |
|
Cross-service tests |
|
Docs |
|
No changes to canopy-snap or canopy-medicaid Rust code. No event-payload shape change.
Verification
Per-step verification
-
cargo nextest run -p canopy-reference— newDenialReasonCoderoundtrip tests pass (each named variant,Other("future_code"), empty-string handling). -
cargo xtask rules check— JDM ruleset validates under zen-engine,o-denial-codecolumn typed correctly. -
cargo nextest run -p canopy-tanf— all existing tests pass (minus the 4 deleted hack tests). -
cargo nextest run -p canopy-snap tsnap_e2e_test— TSNAP cross-service path produces identical event payload (reason ="earned_income") as before — enum’sDisplaymust produce byte-identical strings to the old hack’s output for known codes. -
cargo nextest run -p canopy-medicaid tma_e2e_test— TMA path produces identical event payload. -
Ad-hoc integration probe: post a gross-over TANF denial,
SELECT denial_reason_code FROM tanf_determinationsshowsearned_income(stored as TEXT via enumDisplay). -
cargo xtask policy audit— clean (no citations.toml changes expected; the JDM emits codes that are already cited via therulesets/federal/cross-program-2026.jsontrigger_reasons arrays). -
cargo xtask validate— full battery green.
Plan-level verification
-
The hack function + its 4 unit tests are gone.
rg -n categorize_closure_reasonreturns no hits. -
Cross-service tests still green with unchanged assertions (the event-payload value is unchanged; only the code path generating it changed).
-
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/=== Fixedbullet documenting (a) the hack removal +unemploymentfalse-positive that motivated the fix, and (b) the newDenialReasonCodeenum landing as part of the same MR (not deferred).
Potential Improvements
Out of scope for this plan but worth capturing:
-
Typed
DenialReasonCodeenum in canopy-reference. Resolved 2026-04-22 — folded into this plan’s Steps 9-10 rather than tracked as future polish. Enum lives incrates/canopy-referencewithOther(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 checkcould grow a test mode. -
Medicaid denial-code parity.
rulesets/georgia/medicaid-*.jsonrulesets have similaro-reasonsoutputs. If canopy-medicaid ever grows cross-program event publishing (outside the TMA receiver role), a parallel plan can reuse theDenialReasonCodeenum introduced here — no Rust-type additions required. -
Sanction denial path. The hack has a
sanctionbranch 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 totanf-eligibility.json, the code-mapping is one line of JDM config + one enum variant addition (orOther("sanction")if the subscriber list predates the Rust variant), not a hack reintroduction. -
Build-time enum generation from
cross-program-2026.json. Abuild.rsthat parses the federal ruleset at compile time and generates theDenialReasonCodevariants automatically would remove the hand-maintenance burden. Out of scope today — theOther(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):
Tracked follow-ups (filed 2026-05-04 during PI sweep):
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.
TanfDeterminationderivessqlx::FromRow. Typing the field asOption<DenialReasonCode>requiressqlx::Type+Encode+Decodeimpls on the enum — andcanopy-referenceis 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: Stringalready stores an enum-shaped value as raw text. Keepingdenial_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.rsparses the raw JDM output throughDenialReasonCode::FromStron ingress (theOther(String)escape hatch preserves source-of-truth for codes absent from the Rust variant list), then re-serialises viaDisplaybefore storage. Any caller that wants type safety at the read side callsdet.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 viaDisplay.
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.