Plan: TANF Sanction → Denial / Sanctioned Path (Issue #416)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Audit + forward migration. The current |
Done (2026-05-11) — migration |
2 |
JDM ruleset extension. Add a |
Done (2026-05-11) — deviation: instead of a separate |
3 |
Reference enum + citations. Add |
Done (2026-05-11) — added both |
4 |
Rust integration. Update |
Done (2026-05-11) — sanction + PR state loaded via |
5 |
Status mapping. After the rules call, the existing 7-tuple at |
Done (2026-05-11) — |
6 |
Tests. Six unit cases in |
Done (2026-05-11) — 6 in-process zen-engine tests in |
7 |
Docs. CHANGELOG entry under |
Done (2026-05-11) — CHANGELOG |
Issue: #416
Branch: feat/tanf-sanction-denial-path
Labels: type::feature, priority::medium, service::tanf, program::tanf, workflow::needs-spec
Context
A TANF applicant who is currently under an active work-requirement sanction (PAMMS 1351 first/second/subsequent tier) or who has a verified personal-responsibility violation (PAMMS 1345-1370: immunization, school attendance, prenatal care, TFSP signature, minor living arrangement) must not receive a fresh Approved determination. Today they can — the sanction state on tanf_work_requirements.sanction_level and the per-requirement tanf_personal_responsibilities.status rows are written by other handlers but never read by services/canopy-tanf/src/determine.rs. The eligibility ruleset (rulesets/georgia/tanf-eligibility.json) tests income, deprivation, citizenship, dependent-children, and time-limit gates — but knows nothing about sanctions or PR.
The previous draft of this plan (2026-05-06) targeted file paths that don’t exist (services/canopy-tanf/src/work_requirements.rs, services/canopy-tanf/src/personal_responsibility.rs) — those modules live as HTTP handlers at services/canopy-tanf/src/api/work_requirement_handlers.rs (561 LOC) and services/canopy-tanf/src/api/personal_responsibility_handlers.rs (147 LOC). It also cited determine.rs:478-536 as a "denial-reason synthesis closure" — those lines are actually post-#387 SignableDetermination envelope-build code. The real integration point is the input-assembly + tuple-build region at determine.rs:339-460. This rewrite reflects the actual surface.
Per ADR-003, every eligibility decision flows through a JDM ruleset evaluated by canopy-rules. Adding the sanction/PR gates as Rust-side branching that flips Approved → Denied after the ruleset says approved would put eligibility truth in two places. The fix has to extend the ruleset itself. The Rust side’s responsibility narrows to: load DB state, populate input fields, map the JDM’s status output to the DeterminationStatus enum.
The DeterminationStatus::Sanctioned variant already exists in crates/canopy-reference/src/enums.rs:73 ("Used for TANF work non-compliance"); this plan is what finally emits it. Personal-responsibility violations remain Denied per PAMMS 1345-1370.
Code references
-
services/canopy-tanf/src/determine.rs:339-460— input-assembly, rules-engine call, and 7-tuple status/benefit/code build (the actual integration point — not:478-536, which is the envelope serialiser). -
services/canopy-tanf/src/determine.rs:392-407— theif !elig_result.eligiblebranch where the JDM’s denial_reason_code is currently parsed; the newstatusoutput is consumed here. -
services/canopy-tanf/src/api/work_requirement_handlers.rs— work-requirement HTTP handlers (sanctions are imposed elsewhere; this plan only consumes existing state). -
services/canopy-tanf/src/api/personal_responsibility_handlers.rs— PR HTTP handlers; thestatusfield already acceptspending | compliant | non_compliant | good_cause | exempt. -
services/canopy-tanf/src/rules_client.rs:214-222—TanfRulesClient::evaluate_eligibility(Path B namespaced shape; 5-arg viaevaluate_namespaced → inner.evaluate(name, "tanf", id, envelope, token)at:265-269). -
services/canopy-tanf/migrations/20260325000000_create_tanf_tables.sql:91-103—tanf_work_requirementstable;sanction_level INTEGER DEFAULT 0exists, no lifecycle columns. -
services/canopy-tanf/migrations/20260407000000_add_lump_sum_grg_personal_resp.sql:38-54—tanf_personal_responsibilitiestable with status vocabulary already in place. -
rulesets/georgia/tanf-eligibility.json— the JDM ruleset to extend (198 LOC, singledt-eligdecision table; this plan addsdt-sanctionsupstream of it). -
rulesets/georgia/citations.toml:1158-1228— existing PAMMS 1351 sanction policy citations (tanf.sanctions.*); preserved verbatim. New citations added for the wire codes. -
crates/canopy-reference/src/enums.rs:58-83—DeterminationStatusenum;Sanctionedalready exists at line 73. -
crates/canopy-reference/src/enums.rs:105-131—DenialReasonCodemacro; gainSanctionin the hand-maintained tail.
Scope
In scope:
-
tanf_work_requirementsschema expansion (forward-only, ADR-016):sanction_imposed_at,sanction_expires_at,sanction_reasoncolumns. -
JDM
dt-sanctionsnode inrulesets/georgia/tanf-eligibility.json, upstream of the existingdt-elig. -
Four new
input.*fields plumbed throughTanfEligibilityInputand the determine flow. -
Status flip from Approved → Sanctioned (gating sanction) or Approved → Denied (PR failure) emitted by the ruleset, mapped by Rust into the canonical
DeterminationStatusvariant. -
DenialReasonCode::Sanctionvariant + matching citations.toml entries. -
Six unit tests covering precedence, expiry, and pending-vs-non-compliant distinctions.
-
CHANGELOG
=== Addedentry + canopy-tanf service-page denial-code list update.
Out of scope:
-
Sanction-imposing / sanction-lifting endpoints. Sanctions are written by other handlers and PAMMS 1351 event-history tracking is its own future plan.
-
New PR data ingestion (school attendance, immunizations, etc.) — those arrive via canopy-verification.
-
Re-running determinations when a sanction is added or expires — a renewal/CIC concern, separate plan.
-
Worker-portal UI for the new denial-reason codes — covered by issue #392’s tab wiring.
-
Cross-program disqualification propagation (e.g., SNAP work sanction influencing TANF) — out of scope for this plan; orchestrated separately.
-
A
tanf_sanction_eventsledger table — desirable for audit but a follow-on plan; this MR uses the columns added in Step 1.
Dependencies
-
DeterminationStatus::Sanctionedalready exists (crates/canopy-reference/src/enums.rs:73) — no canopy-reference enum gate. -
tanf_personal_responsibilitiestable already exists (migration20260407000000); no schema work for PR. -
ADR-003 (ruleset-as-data) — drives the JDM-extension design choice over Rust-side branching.
-
ADR-011 (policy citations) — drives the new citations.toml entries;
cargo xtask policy auditmust stay green. -
ADR-016 (forward-only migrations) — drives the Step 1 expand-only column addition.
-
Post-#424 rules-client signature (5-arg
evaluate(name, source, id, envelope, bearer_token)) — already in place atservices/canopy-tanf/src/rules_client.rs:265-269; no client-shape work needed. -
No dependencies on other open plans.
Design
Why JDM extension, not Rust branching (ADR-003)
The prior plan’s design — Rust-side evaluate_sanctions / evaluate_personal_responsibility helpers that mutate the determination after the ruleset returns — would put eligibility truth in two locations: the JDM file (income / deprivation / time-limit) and Rust (sanctions / PR). Per ADR-003 every gate must live in JDM. The chosen design moves the gate into tanf-eligibility.json as a dt-sanctions decision-table node placed upstream of the existing dt-elig. The Rust side reads DB state, marshals it through TanfEligibilityInput, and trusts the ruleset’s status output verbatim.
JDM shape (post-extension)
"nodes": [
{ "id": "input", "type": "inputNode" },
{ "id": "dt-sanctions", "type": "decisionTableNode" }, // new
{ "id": "dt-elig", "type": "decisionTableNode" },
{ "id": "output", "type": "outputNode" }
],
"edges": [
{ "sourceId": "input", "targetId": "dt-sanctions" },
{ "sourceId": "dt-sanctions", "targetId": "dt-elig" }, // pass-through unless sanction/PR hits
{ "sourceId": "dt-elig", "targetId": "output" }
]
dt-sanctions uses hitPolicy: "first" and passThrough: true so a sanctioned outcome short-circuits the rest of the table; a non-hit (no sanction, no PR failure) falls through to dt-elig with the existing seven rules.
dt-sanctions rule sketch
| Rule ID | Trigger | o-status |
o-denial-code |
|---|---|---|---|
|
|
|
|
|
|
|
First element’s code (PAMMS 1345-1370) |
|
|
(empty — fall through to |
(empty) |
|
(catch-all) |
(empty — fall through to |
(empty) |
PAMMS 1351 first-hit ordering means sanction beats PR-failure when both fire, which the hitPolicy: "first" already enforces given rule ordering.
Rust-side input shape
// services/canopy-tanf/src/rules_client.rs — TanfEligibilityInput gains:
pub struct TanfEligibilityInput {
// ... existing 9 fields unchanged ...
pub active_sanction_level: i32, // 0 = none
pub sanction_expired: bool, // true if expires_at < today
pub personal_responsibility_failures: Vec<PrFailure>,
pub personal_responsibility_pending: bool,
}
#[derive(Serialize)]
pub struct PrFailure {
pub requirement_type: String, // e.g. "school_attendance"
pub code: String, // PAMMS 1345-1370 wire code
}
Status mapping (Rust)
// services/canopy-tanf/src/determine.rs (replaces the boolean branch at :391-411)
let status_enum = match elig_result.status.as_str() {
"sanctioned" => DeterminationStatus::Sanctioned,
"denied" => DeterminationStatus::Denied,
"approved" => DeterminationStatus::Approved,
other => return Err(ApiError::internal(
"unknown JDM status",
format!("tanf-eligibility emitted unknown status `{other}`"),
)),
};
The time_limit_exceeded short-circuit at :377-391 continues to emit DeterminationStatus::TimeLimitExceeded directly, bypassing the ruleset for that one pre-determined case (preserved as-is from the current code).
Schema migration (ADR-016, expand-only)
-- 20260XXX_add_sanction_lifecycle.sql
ALTER TABLE tanf_work_requirements
ADD COLUMN sanction_imposed_at TIMESTAMPTZ,
ADD COLUMN sanction_expires_at DATE,
ADD COLUMN sanction_reason TEXT;
No backfill — existing sanction_level > 0 rows present as "active indefinitely" until a future plan introduces sanction event-history. This is consistent with ADR-016’s expand-contract guidance: adding nullable columns is non-destructive; the contract step (dropping the column, if ever) requires a separate forward migration.
Files Touched
| File | Change |
|---|---|
|
New forward migration adding |
|
Add three |
|
Add |
|
New |
|
Add |
|
Extend |
|
Load work-requirement + PR rows before the rules call; populate the new input fields; replace the boolean |
|
Six new test cases (active sanction, expired sanction, PR non-compliant, PR pending, sanction+PR precedence, no-gate baseline). |
|
One new test asserting |
|
|
|
Add |
Verification
-
cargo xtask test -p canopy-tanf— unit tests pass, including the six new sanction/PR cases. -
cargo xtask test -p canopy-rules— the new JDM-eval test passes. -
cargo xtask policy audit— green; the two new citation keys are present and reference PAMMS pages that resolve undercargo xtask policy sync-cache. -
cargo xtask rules check—tanf-eligibility.jsonstill compiles under zen-engine 0.55 after thedt-sanctionsnode is added. -
cargo xtask docs plan-lint— clean (every Status cell uses a canonical token). -
cargo xtask validate— full battery green (fmt + clippy + nextest + docker build). -
Manual smoke via
cargo xtask dev start+ a hand-crafted determination request: a household whose applicant hastanf_work_requirements.sanction_level = 1, sanction_expires_at = today + 30 daysproducesstatus = "sanctioned",benefit_amount = null,denial_reason_code = "sanction"; an otherwise-eligible household with onetanf_personal_responsibilitiesrow atstatus = "non_compliant"producesstatus = "denied"with the PAMMS code; an applicant with the same sanction row butsanction_expires_at = yesterdayproducesstatus = "approved"(sanction has lifted).
Documentation Updates
-
CHANGELOG.adoc— entry under== Unreleased/=== Addedcovering the new gate + emission ofDeterminationStatus::Sanctioned. -
docs/modules/ROOT/pages/services/canopy-tanf.adoc— denial-reason coverage list extended withsanctionand PAMMS 1345-1370 codes; status list extended withSanctioned. -
.claude/docs/services.md— TANF row’s notes column updated to mention the sanction/PR gate. -
Plan moves to
plans/archive/post-merge per ADR-013.