Plan: TANF Sanction → Denial / Sanctioned Path (Issue #416)

On this page

Status

Step Description Status

1

Audit + forward migration. The current tanf_work_requirements table tracks sanction_level INTEGER DEFAULT 0 (PAMMS 1351 progressive tier: 0, 1, 2, 3) but has no notion of when a sanction took effect or when it lifts — every sanction looks permanently-active. Add a forward-only migration (ADR-016) at services/canopy-tanf/migrations/{ts}_add_sanction_lifecycle.sql introducing sanction_imposed_at TIMESTAMPTZ, sanction_expires_at DATE, and sanction_reason TEXT columns on tanf_work_requirements. All nullable, no backfill — existing sanction_level > 0 rows are treated as active indefinitely until a future PAMMS 1351 event-history plan supersedes this (out of scope). Store / model layer (services/canopy-tanf/src/store/models.rs:93-105) gains matching Option<…​> fields.

Done (2026-05-11) — migration 20260511000000_add_sanction_lifecycle.sql; TanfWorkRequirement extended with sanction_imposed_at: Option<DateTime<Utc>>, sanction_expires_at: Option<NaiveDate>, sanction_reason: Option<String>. Rows with sanction_expires_at == None are treated as "active indefinitely" by the determine.rs gate per the migration commentary.

2

JDM ruleset extension. Add a dt-sanctions decision-table node to rulesets/georgia/tanf-eligibility.json before the existing dt-elig node. The new node reads four new namespaced inputs — input.active_sanction_level, input.sanction_expired, input.personal_responsibility_failures (array of {requirement_type, code}), and input.personal_responsibility_pending (bool, separates "verification not yet collected" from "verified non-compliant") — and emits eligible, denial_reasons, denial_reason_code, and a new status output column with values "sanctioned" | "denied" | "approved" so the Rust side can branch on the canonical status without re-interpreting denial codes. Rules: (a) r-sanction-active when sanction_level >= 1 && !sanction_expiredstatus = "sanctioned", code = "sanction" (new variant, see Step 3); (b) r-pr-non-compliant when personal_responsibility_failures is non-empty → status = "denied", code per the failure type per PAMMS 1345-1370; (c) r-pr-pending when personal_responsibility_pending == true → fall through to current eligibility logic (pending verification is not a denial). The existing seven dt-elig rules remain unchanged and feed the unified output node only when the sanction/PR gate passes.

Done (2026-05-11) — deviation: instead of a separate dt-sanctions node, the rules land at positions 0-1 inside the existing dt-elig table (precedent: the existing r-tl-exceeded rule at position 0 already uses the same "gate at the top of the table" pattern, and hitPolicy: "first" gives the desired short-circuit semantics without a separate node). All 10 dt-elig rules now have the 4 new input-column bindings (mostly "" = no constraint). All 10 rules emit the new o-status output column. Ruleset version bumped to v2.2. Fixture updated to include the 4 new input fields zeroed out + assert status == "approved".

3

Reference enum + citations. Add DenialReasonCode::Sanction to crates/canopy-reference/src/enums.rs (the hand-maintained tail of gen_denial_reason_code!, alongside TimeLimit and Unspecified at lines 118-123). PAMMS 1345-1370 personal-responsibility codes are already representable via DenialReasonCode::Other(String) — no new variants needed unless a downstream subscriber needs exact-match. Per ADR-011, every new wire token needs a citation: add [citations."tanf.sanctions.denial_code"] and [citations."tanf.personal_responsibility.denial_codes"] entries in rulesets/georgia/citations.toml pointing to PAMMS 1351 and PAMMS 1345-1370 respectively. The existing nine tanf.sanctions. citations at rulesets/georgia/citations.toml:1158-1228 stay as-is; the new entries cite the *codes (not the progressive-tier policy values).

Done (2026-05-11) — added both DenialReasonCode::Sanction AND DenialReasonCode::PersonalResponsibility as explicit variants (lighter footprint than Other(String) for exact-match downstream subscribers; matches the ruleset’s o-denial-code literals). citations.toml gains the two new entries. cargo xtask policy audit clean (211/204).

4

Rust integration. Update services/canopy-tanf/src/determine.rs:339-411 — the section that builds TanfEligibilityInput, calls rules.evaluate_eligibility(…​), and assembles the 7-tuple at lines 369-460. Before the rules call (currently at :345-366), load sanction state and PR rows: let work_req = store::get_or_create_work_requirement(&db, applicant_person_id, Some(tanf_app.id)).await?; and let pr_rows = store::list_personal_responsibilities(&db, tanf_app.id).await?;. Compute sanction_expired = work_req.sanction_expires_at.is_some_and(|d| d < today()), derive personal_responsibility_failures as the rows with status == "non_compliant", and personal_responsibility_pending as any row with status == "pending". Extend TanfEligibilityInput (in services/canopy-tanf/src/rules_client.rs) with the four new fields so they serialise under input.* per the Path B namespaced shape. The rules-client signature stays the post-#424 5-arg form (evaluate(name, "tanf", id, envelope, bearer_token) — already used at rules_client.rs:268); no call-site change there.

Done (2026-05-11) — sanction + PR state loaded via store::get_or_create_work_requirement and store::list_personal_responsibilities in the new Step 5 block at determine.rs. TanfEligibilityInput extended with active_sanction_level: i32, sanction_expired: bool, personal_responsibility_failures: Vec<PrFailure>, personal_responsibility_pending: bool. New PrFailure { requirement_type, code } helper struct.

5

Status mapping. After the rules call, the existing 7-tuple at services/canopy-tanf/src/determine.rs:369-460 branches on time_limit_exceeded and elig_result.eligible. Replace the boolean branch with a match on elig_result.status (new field, populated by Step 2’s o-status JDM output): "sanctioned" ⇒ DeterminationStatus::Sanctioned, "denied" ⇒ DeterminationStatus::Denied, "approved" ⇒ DeterminationStatus::Approved. The time-limit short-circuit at :377-391 continues to emit DeterminationStatus::TimeLimitExceeded. The stored tanf_determinations.status column already holds TEXT so widening the persisted vocabulary needs no migration — but services/canopy-tanf/src/determine.rs:519 (the TanfDetermination { status: …​, …​ } builder) must use DeterminationStatus::*.to_string() rather than the current hardcoded "approved" / "denied" literals at :412, :452. Update the CHECK-constraint-free string in three sites: :379, :413, :452.

Done (2026-05-11) — elig_result.status == "sanctioned"DeterminationStatus::Sanctioned.to_string(); everything else in the !eligible branch stays as "denied". The TanfEligibilityOutput::status field carries #[serde(default)] for back-compat with pre-#416 ruleset revisions.

6

Tests. Six unit cases in services/canopy-tanf/src/determine.rs mod tests (alongside the existing compute_tanf_earned_income tests at :554-722): (a) active gating sanction → DeterminationStatus::Sanctioned, denial_reason_code "sanction", benefit_amount = None; (b) expired sanction (sanction_expires_at < today) → falls through to normal eligibility path → Approved; (c) personal-responsibility row with status = "non_compliant"DeterminationStatus::Denied with PAMMS code; (d) all PR rows status = "pending" → falls through → Approved (pending-verification is not a denial); (e) sanction + PR failure simultaneously → Sanctioned (sanction precedence per PAMMS 1351 first-hit ordering — the JDM hitPolicy: "first" already encodes this); (f) no work_req row and no PR rows → existing behaviour unchanged. JDM-level test alongside the existing rules-engine eval tests in services/canopy-rules/tests/rules_test.rs confirms dt-sanctions evaluates correctly given the four new inputs.

Done (2026-05-11) — 6 in-process zen-engine tests in determine::tests::sanctions covering all 6 cases. Tests evaluate rulesets/georgia/tanf-eligibility.json directly (zen-engine added as canopy-tanf dev-dep since the fixture system is one-fixture-per-ruleset and the non-happy paths need separate coverage). 94/94 canopy-tanf tests pass after the change.

7

Docs. CHANGELOG entry under === Added (new behaviour, not a fix of shipped logic — the prior plan’s "Fixed" framing was inaccurate; the sanction-denial path never existed). Update docs/modules/ROOT/pages/services/canopy-tanf.adoc to list Sanctioned and the sanction/PR denial codes as outputs. Plan moves to plans/archive/ post-merge.

Done (2026-05-11) — CHANGELOG === Added entry; docs/modules/ROOT/pages/api/canopy-tanf.adoc lists the new status + denial codes; CLAUDE.md service-row note extended. Plan archived.

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 — the if !elig_result.eligible branch where the JDM’s denial_reason_code is currently parsed; the new status output 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; the status field already accepts pending | compliant | non_compliant | good_cause | exempt.

  • services/canopy-tanf/src/rules_client.rs:214-222TanfRulesClient::evaluate_eligibility (Path B namespaced shape; 5-arg via evaluate_namespaced → inner.evaluate(name, "tanf", id, envelope, token) at :265-269).

  • services/canopy-tanf/migrations/20260325000000_create_tanf_tables.sql:91-103tanf_work_requirements table; sanction_level INTEGER DEFAULT 0 exists, no lifecycle columns.

  • services/canopy-tanf/migrations/20260407000000_add_lump_sum_grg_personal_resp.sql:38-54tanf_personal_responsibilities table with status vocabulary already in place.

  • rulesets/georgia/tanf-eligibility.json — the JDM ruleset to extend (198 LOC, single dt-elig decision table; this plan adds dt-sanctions upstream 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-83DeterminationStatus enum; Sanctioned already exists at line 73.

  • crates/canopy-reference/src/enums.rs:105-131DenialReasonCode macro; gain Sanction in the hand-maintained tail.

Scope

In scope:

  • tanf_work_requirements schema expansion (forward-only, ADR-016): sanction_imposed_at, sanction_expires_at, sanction_reason columns.

  • JDM dt-sanctions node in rulesets/georgia/tanf-eligibility.json, upstream of the existing dt-elig.

  • Four new input.* fields plumbed through TanfEligibilityInput and 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 DeterminationStatus variant.

  • DenialReasonCode::Sanction variant + matching citations.toml entries.

  • Six unit tests covering precedence, expiry, and pending-vs-non-compliant distinctions.

  • CHANGELOG === Added entry + 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_events ledger table — desirable for audit but a follow-on plan; this MR uses the columns added in Step 1.

Dependencies

  • DeterminationStatus::Sanctioned already exists (crates/canopy-reference/src/enums.rs:73) — no canopy-reference enum gate.

  • tanf_personal_responsibilities table already exists (migration 20260407000000); 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 audit must 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 at services/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

r-sanction-active

active_sanction_level >= 1 && sanction_expired == false

"sanctioned"

"sanction"

r-pr-non-compliant

personal_responsibility_failures != []

"denied"

First element’s code (PAMMS 1345-1370)

r-pr-pending-passthrough

personal_responsibility_pending == true

(empty — fall through to dt-elig)

(empty)

r-no-gate

(catch-all)

(empty — fall through to dt-elig)

(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

services/canopy-tanf/migrations/{ts}_add_sanction_lifecycle.sql

New forward migration adding sanction_imposed_at, sanction_expires_at, sanction_reason to tanf_work_requirements.

services/canopy-tanf/src/store/models.rs:93-105

Add three Option<…​> fields to TanfWorkRequirement matching the new columns.

rulesets/georgia/tanf-eligibility.json

Add dt-sanctions decision-table node, new input.* field bindings, new o-status output column. Edge input → dt-sanctions → dt-elig → output.

rulesets/georgia/citations.toml

New [citations."tanf.sanctions.denial_code"] and [citations."tanf.personal_responsibility.denial_codes"] entries citing PAMMS 1351 and 1345-1370. Existing tanf.sanctions.* entries at :1158-1228 unchanged.

crates/canopy-reference/src/enums.rs:118-123

Add DenialReasonCode::Sanction variant in the hand-maintained tail of gen_denial_reason_code!.

services/canopy-tanf/src/rules_client.rs

Extend TanfEligibilityInput with active_sanction_level, sanction_expired, personal_responsibility_failures, personal_responsibility_pending. Extend TanfEligibilityOutput with status: String.

services/canopy-tanf/src/determine.rs:339-460

Load work-requirement + PR rows before the rules call; populate the new input fields; replace the boolean elig_result.eligible branch with a match on elig_result.status mapping to DeterminationStatus; use the enum’s to_string() for the persisted status field.

services/canopy-tanf/src/determine.rs (test module at :554-722)

Six new test cases (active sanction, expired sanction, PR non-compliant, PR pending, sanction+PR precedence, no-gate baseline).

services/canopy-rules/tests/rules_test.rs

One new test asserting dt-sanctions short-circuits correctly when invoked through canopy-rules' eval path.

CHANGELOG.adoc

=== Added entry naming the new behaviour and the DeterminationStatus::Sanctioned emission path.

docs/modules/ROOT/pages/services/canopy-tanf.adoc

Add Sanctioned to the determination-status list and add sanction + PR codes to the denial-reason coverage section.

Verification

  1. cargo xtask test -p canopy-tanf — unit tests pass, including the six new sanction/PR cases.

  2. cargo xtask test -p canopy-rules — the new JDM-eval test passes.

  3. cargo xtask policy audit — green; the two new citation keys are present and reference PAMMS pages that resolve under cargo xtask policy sync-cache.

  4. cargo xtask rules checktanf-eligibility.json still compiles under zen-engine 0.55 after the dt-sanctions node is added.

  5. cargo xtask docs plan-lint — clean (every Status cell uses a canonical token).

  6. cargo xtask validate — full battery green (fmt + clippy + nextest + docker build).

  7. Manual smoke via cargo xtask dev start + a hand-crafted determination request: a household whose applicant has tanf_work_requirements.sanction_level = 1, sanction_expires_at = today + 30 days produces status = "sanctioned", benefit_amount = null, denial_reason_code = "sanction"; an otherwise-eligible household with one tanf_personal_responsibilities row at status = "non_compliant" produces status = "denied" with the PAMMS code; an applicant with the same sanction row but sanction_expires_at = yesterday produces status = "approved" (sanction has lifted).

Documentation Updates

  • CHANGELOG.adoc — entry under == Unreleased / === Added covering the new gate + emission of DeterminationStatus::Sanctioned.

  • docs/modules/ROOT/pages/services/canopy-tanf.adoc — denial-reason coverage list extended with sanction and PAMMS 1345-1370 codes; status list extended with Sanctioned.

  • .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.

Edit this page · default