Plan: JDM Ruleset Schema Rewrite

On this page

Status

Step Description Status

0

Branch + this plan-of-record

Done (2026-04-12)

1

Foundation: cargo xtask rules check + auto-importer scans rulesets/federal/ + honest compile integration test

Done (2026-04-12)

2

Consolidate canopy-tanf + canopy-medicaid onto shared canopy-rules-client (fixes the pre-existing ruleset_name/rule_set_name field name typo and standardizes bearer forwarding)

Done (2026-04-12)

2.5

Refactor canopy-rules to adopt zen-engine FilesystemLoader + LocalPoolHandle SDK best practices (inserted mid-plan — see errata below)

Done (2026-04-12)

3

Rewrite SNAP rulesets (snap-eligibility, snap-benefit-calculation, snap-alien-eligibility); remove unwrap_or masks in alien_eligibility result parsing; add strong-assertion determine integration tests

Done (2026-04-12)

4

Rewrite TANF rulesets (tanf-eligibility, tanf-benefit-calculation, tanf-work-requirements); add strong-assertion determine and work-requirements integration tests

Done (2026-04-12)

5

Rewrite Medicaid rulesets (medicaid-magi, medicaid-non-magi, chip-eligibility, medicaid-eligibility-hierarchy) and migrate canopy-medicaid off the inline Rust evaluators per ADR-003: delete evaluate_magi_coa / evaluate_chip_coa / evaluate_non_magi_coa, wire the rules client, convert the 30+ Rust unit tests into HTTP-path integration tests

Done (2026-04-12)

6

Rewrite CAPS + WIC stub rulesets as minimal valid zen 0.55 graphs

Done (2026-04-12)

7

Wire cargo xtask rules check into cargo xtask validate and into GitLab CI; remove any remaining 500-skip comments

Done (2026-04-12)

8

End-to-end verification: cargo xtask rules check exit 0, all integration tests pass without skips, ADR-003 honored across SNAP/TANF/Medicaid, citation baseline unchanged

Done (2026-04-12)

Branch: fix/jdm-ruleset-rewrite

Acceptance criterion: cargo xtask rules check exits 0, every actively-called ruleset has at least one strong-assertion integration test that passes against devstack with no if status == 500 { skip } blocks, and grep -n "ruleset_name\|_rules:\|evaluate_magi_coa\|evaluate_chip_coa\|evaluate_non_magi_coa\|unwrap_or(false)" services/canopy-\{snap,tanf,medicaid\}/src returns zero matches.

Context

Every JDM ruleset file under rulesets/georgia/ and rulesets/federal/ was authored against an invented schema that has never matched zen-engine. The first ruleset commit (bbb51a7, 2026-03-26, "Add rulesets, CI pipeline") created the files claiming "valid zen-engine JSON" but the rule format was wrong from day one. The follow-up refactor (4d4b274, "move SNAP eligibility logic to JDM rulesets") expanded on the broken stub format. No test in the project has ever compiled a real (non-stub) ruleset: services/canopy-rules/tests/rules_test.rs only exercises a 2-node empty input→output graph, and the unit tests in canopy-snap/tanf/medicaid test helper math (monthly amount conversion, deduction calculation), never the actual determine() path.

The result: POST /v1/determine for SNAP and TANF returns 500 because canopy-rules 404s on every real ruleset ("rule set not found: georgia-snap-eligibility"). canopy-medicaid masks the bug at services/canopy-medicaid/src/determine.rs:66 by binding the rules client as _rules: &MedicaidRulesClient — the underscore makes it deliberately unused — and calling inline Rust evaluators (evaluate_magi_coa, evaluate_chip_coa, evaluate_non_magi_coa at lines 280-419) instead. That violates ADR-003 (Ruleset-as-data: all eligibility logic in versioned JDM files evaluated by shared canopy-rules).

The schema delta, verified against gorules/zen 0.55 test fixtures at https://github.com/gorules/zen/tree/master/test-data/graphs (aml.json, expression.json):

Field Current (broken) zen 0.55 (required)

root

{name, version, nodes, edges}

needs contentType: "application/vnd.gorules.decision"

edges[]

{sourceId, targetId, type}

needs id

expressionNode.expressions[]

{key, value}

needs id

decisionTableNode.inputs[]

{id, name, field}

needs type: "expression"

decisionTableNode.outputs[]

{id, name, field}

needs type: "expression"

decisionTableNode.rules[]

verbose {conditions: [{id, value}], outputs: [{id, value}], _description}

flat {_id, "<input_id>": "<zen_expr>", "<output_id>": "<value>", _description?}

The verbose rule format also uses cross-row id references (e.g. {id: "gi-income", value: "⇐ gi-limit"} where gi-limit is itself another condition row) which zen-engine has never supported. Translation requires inlining the limit value as a ZEN expression directly against fields already passed in rules_input from the calling service. For SNAP, every parameter the rules need (gross_income_limit, asset_limit, max_allotment, etc.) is already populated by services/canopy-snap/src/params.rs from rulesets/federal/snap-*.json and jurisdiction.toml, then injected at services/canopy-snap/src/determine.rs:201-231 — so no new Rust plumbing is required for SNAP/TANF. Medicaid needs Rust changes (Step 5) because the inline evaluators are the only callers today.

Pre-existing latent bugs uncovered during planning

While inspecting the call sites, three additional latent bugs were discovered that a ruleset rewrite alone would not fix. These are addressed by Step 2 + Step 3:

  1. ruleset_name field name typo (TANF + Medicaid). canopy-tanf and canopy-medicaid each ship a bespoke rules_client.rs that POSTs to /v1/evaluate with body field ruleset_name (services/canopy-tanf/src/rules_client.rs:157, services/canopy-medicaid/src/rules_client.rs:174). canopy-rules' EvaluateRequest (services/canopy-rules/src/api/mod.rs:43) deserializes the field as rule_set_name. Result: every TANF and Medicaid rules call returns 400 ("missing field rule_set_name`") — even after the JDM files are fixed. SNAP avoids the bug because it re-exports the shared `canopy-rules-client crate (services/canopy-snap/src/rules_client.rs:4) which uses the correct field name. Fix: Step 2 deletes the bespoke clients and replaces them with shared canopy-rules-client::RulesClient.

  2. alien_eligibility fallback never implemented. The doc comment at services/canopy-snap/src/alien_eligibility.rs:80-81 promises "calls {jurisdiction}-snap-alien-eligibility if it exists, falling back to federal-snap-alien-eligibility`" but the code at lines 87-98 only calls the jurisdiction-prefixed name with no fallback. The federal file’s `name is federal-snap-alien-eligibility and there is no georgia-snap-alien-eligibility file. Result: every alien eligibility call 404s. Fix: in Step 3 the federal file’s name field changes to georgia-snap-alien-eligibility AND the auto-importer scans rulesets/federal/ (Step 1) so the file is loaded. The plan does not implement the documented fallback semantics — that’s deferred to a later plan if jurisdictions actually need to override the federal alien rules.

  3. Soft-fallback masks in alien_eligibility result parsing. Lines 100-115 use unwrap_or(false), unwrap_or("no reason provided"), and unwrap_or("7 CFR 273.4") which silently mask any output schema mismatch. After Step 3 these are replaced with hard ok_or_else returning ApiError::internal so a missing field surfaces as a 500 with a clear error rather than a wrong result.

These bugs are pre-existing — they did not originate in recent work — and they explain why no integration test has ever passed for these code paths.

Prior remediation

The rules engine race condition (reload_all() ran before the auto-import loop populated the DB, so the cache stayed empty on fresh DB) was fixed in commit 462b73e ("feat: refresh canopy-rules engine cache after auto-import"). The ABAWD FK-violation 500 was fixed in fbb8d0a. The canopy-reporting service-to-service bearer forwarding gap was fixed in 0ac1ea8. The audit_events_archive schema drift was fixed in b4c9bfc. Those four commits landed directly on main before this branch was created; they do not appear as plan steps here.

Scope

In scope:

  • Rewrite all 11 files in rulesets/georgia/.json against zen-engine 0.55’s actual schema, preserving each ruleset’s *current intent (not full PAMMS alignment).

  • Rewrite rulesets/federal/snap-alien-eligibility.json (the only JDM file in rulesets/federal/; the rest are parameter JSONs loaded directly by params.rs and not affected).

  • Extend the canopy-rules auto-importer to also scan rulesets/federal/ for JDM files (skipping non-JDM parameter files by checking for a top-level nodes array).

  • Add cargo xtask rules check that compiles every JDM file under rulesets/ via zen_engine::Decision::from(DecisionContent) and exits non-zero on any failure.

  • Wire xtask rules check into cargo xtask validate and into .gitlab-ci.yml as a fast lint-stage job.

  • Migrate canopy-medicaid off the inline Rust evaluators per ADR-003: switch services/canopy-medicaid/src/determine.rs:130-132 to call MedicaidRulesClient::evaluate_magi/evaluate_chip/evaluate_non_magi/evaluate_hierarchy, delete evaluate_magi_coa/evaluate_chip_coa/evaluate_non_magi_coa and their #[cfg(test)] mod tests, replace those tests with integration tests against the live HTTP path.

  • Add real determination integration tests for SNAP, TANF, and Medicaid that POST against the running services and assert on specific output fields with assert_eq!/matches!. No skips, no unwrap_or(false) masks.

  • Remove the "broken JDM ruleset" skips in canopy-snap/tests/snap_test.rs::post_determine_returns_determination and canopy-tanf/tests/tanf_test.rs::post_determine_returns_determination (added with honest reasons earlier on main while the plan was being written).

Out of scope:

  • Full PAMMS-aligned business logic (covered by separate plans: SNAP PAMMS Alignment, TANF PAMMS Alignment, Medicaid Eligibility, and Medicaid Implementation per the roadmap). This rewrite preserves current intent — pass-through stubs stay pass-through stubs in correct schema; real logic gets translated rule-for-rule from current Rust/JDM into correct schema.

  • New federal parameter completeness work (covered by Federal Parameter Completion).

  • CAPS and WIC service implementation (out of scope for UAT; only the JDM stub files are touched here).

Design

Reference compile path

RulesEngine::compile_rule_set at services/canopy-rules/src/engine.rs:166-169 is the canonical compile call:

pub fn compile_rule_set(content: &serde_json::Value) -> anyhow::Result<ZenDecision> {
    let decision_content: DecisionContent = serde_json::from_value(content.clone())?;
    Ok(ZenDecision::from(decision_content))
}

xtask rules check reuses this exact pattern. Since xtask cannot depend on canopy-rules (would create a workspace cycle), xtask adds zen-engine as a direct dependency and inlines the same two-line compile.

Auto-importer extension

Today services/canopy-rules/src/main.rs:29-30 only scans rulesets/{settings.jurisdiction}/. Add a second scan over rulesets/federal/ that reads every *.json, parses to serde_json::Value, skips files that lack a top-level nodes array (these are parameter JSONs like snap-allotments-2026.json), and for files with nodes calls store::upsert_rule_set_by_name with the file’s name field.

Scan order matters: federal first, then jurisdiction. The jurisdiction scan uses the same upsert_rule_set_by_name helper, so any name collision means the jurisdiction file wins (last write). This gives implicit federal→jurisdiction override semantics for free, which is the design Step 3’s alien-eligibility section depends on. The post-import engine.reload_all() already in place after commit 462b73e covers both.

Critical guardrail: the federal scan must skip non-JDM parameter files (fpl-2026.json, smi-2026.json, snap-allotments-2026.json, snap-budgeting-factors.json, snap-deductions-2026.json, snap-income-limits-2026.json) by testing value.get("nodes").map(|n| n.is_array()).unwrap_or(false). These parameter files have a flat key/value structure with no nodes array.

Test discipline

  • Every integration test guards on canopy_test_lib::infrastructure_available() (existing convention).

  • Every assertion uses assert_eq! / matches! against specific expected values. No assert!(…​ .is_some()), no unwrap_or(false).

  • Tests that exercise sad paths assert on the exact denial reason string the ruleset emits.

  • Tests for happy paths assert on the exact status: "approved", the benefit amount range, and the basis pathway.

ZEN expression cheat sheet

  • Decision-table cells: bare ZEN expressions evaluated against the input. Example: ⇐ max_income, > 0, == "ssi_recipient", null, empty string "" (always-true wildcard).

  • Output cells: ZEN expressions producing the value to write to the named output field. String literals are double-quoted: "income_over_100_pct_fpl". Booleans: true/false. References to input fields by name: gross_income.

  • Expression nodes: {id, key, value} where key is the output field name and value is the ZEN expression. References to prior expressions in the same node use $.<key>.

  • Switch node statements: {id, condition} where condition is a ZEN boolean expression.

  • All ids must be unique within the file but otherwise are arbitrary strings.

Operators and built-ins used in this rewrite:

  • Boolean: and, or, not

  • Comparison: ==, !=, <, , >, >=

  • Conditional: if cond then a else b

  • Arithmetic: +, -, *, /, %

  • Built-ins: round(x, 2), max(a, b), min(a, b), sum(arr), string(x) (cast)

  • Null: bare null

  • Self-reference inside an expressionNode: $.<key>

When in doubt, write the rule into a minimal test fixture and POST it to canopy-rules' /v1/rule-sets endpoint to verify it compiles before incorporating into the larger ruleset.

Inventory of files to rewrite

File Active caller Output contract

rulesets/georgia/snap-eligibility.json

services/canopy-snap/src/determine.rs:235

eligible, status, basis, benefit_amount, benefit_unit, gross_income_test_passed/_basis, asset_test_passed/_basis, net_income_test_passed/_basis

rulesets/georgia/snap-benefit-calculation.json

(currently unused)

minimal compile-only stub

rulesets/federal/snap-alien-eligibility.json

services/canopy-snap/src/alien_eligibility.rs:93

eligible, reason, citation

rulesets/georgia/tanf-eligibility.json

services/canopy-tanf/src/rules_client.rs:131

eligible, denial_reasons, gross_income_test_passed, net_income_test_passed, deprivation_test_passed

rulesets/georgia/tanf-benefit-calculation.json

services/canopy-tanf/src/rules_client.rs:139

benefit_amount, effective_date, expiration_date, calculation_basis

rulesets/georgia/tanf-work-requirements.json

services/canopy-tanf/src/rules_client.rs:147

required, exempt, exemption_reason, hours_met, minimum_hours_required, total_hours_reported

rulesets/georgia/medicaid-magi.json

new in Step 5 (replaces inline evaluate_magi_coa)

parent_caretaker_eligible, children_under_19_eligible, pregnant_women_eligible, pathways_eligible, former_foster_care_eligible, fpl_percentage, applicable_fpl_threshold, denial_reasons

rulesets/georgia/medicaid-non-magi.json

new in Step 5 (replaces inline evaluate_non_magi_coa)

ssi_medicaid_eligible, qmb_eligible, slmb_eligible, qi1_eligible, amn_eligible, amn_spend_down_amount, nursing_home_eligible, denial_reasons

rulesets/georgia/chip-eligibility.json

new in Step 5 (replaces inline evaluate_chip_coa)

eligible, fpl_percentage, premium_tier, monthly_premium_cents, family_cap_premium_cents, premium_exempt, premium_exemption_reason, denial_reasons

rulesets/georgia/medicaid-eligibility-hierarchy.json

new in Step 5 (EE15 cascade)

assigned_coa, assigned_track, rationale

rulesets/georgia/caps-eligibility.json

(no service handler yet)

minimal compile-only stub

rulesets/georgia/wic-eligibility.json

(no service handler yet)

minimal compile-only stub

Step dependency graph

  • Step 1 must land before any later step (everything depends on the xtask rules check gate and the federal scan).

  • Step 2 must land before Step 4 and Step 5 (the TANF/Medicaid rewrites depend on the consolidated rules client; otherwise the ruleset_name typo silently breaks them).

  • Step 7 (CI enforcement) must land last so the gate only goes live once every file compiles.

  • Steps 3, 4, 5, 6 are otherwise independent.

Steps

Step 1: Foundation — cargo xtask rules check, federal auto-importer scan, honest compile test

Files:

  • xtask/Cargo.toml — add zen-engine = { workspace = true }

  • xtask/src/cmd/rules.rs — NEW

  • xtask/src/cmd/mod.rspub mod rules;

  • xtask/src/main.rs — register Rules { Check } subcommand

  • xtask/src/cmd/validate.rs — call rules::check() after cargo fmt --check and before cargo clippy (Step 7 makes this enforcing; Step 1 only adds the call)

  • services/canopy-rules/src/main.rs — add federal scan before the jurisdiction scan

  • services/canopy-rules/tests/rules_test.rs — add every_real_ruleset_compiles test

xtask/src/cmd/rules.rs outline. The function returns anyhow::Result<()> and uses bail! on any compile failure; the xtask main.rs propagates the error to the process exit code via the standard anyhow::Result<()> main pattern, so a failed check exits non-zero. Do not silently eprintln! and return Ok(()):

// SPDX-License-Identifier: AGPL-3.0-or-later
//! `cargo xtask rules check` — compile every JDM ruleset file via zen-engine.

use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use zen_engine::Decision;
use zen_engine::model::DecisionContent;

pub fn check() -> Result<()> {
    let mut failures: Vec<(PathBuf, String)> = Vec::new();
    let mut compiled = 0usize;
    for dir in ["rulesets/georgia", "rulesets/federal"] {
        for entry in std::fs::read_dir(dir)
            .with_context(|| format!("read_dir {dir}"))?
        {
            let path = entry?.path();
            if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
            let raw = std::fs::read_to_string(&path)?;
            let value: serde_json::Value = serde_json::from_str(&raw)
                .with_context(|| format!("parse {}", path.display()))?;
            // Skip non-JDM parameter files.
            if !value.get("nodes").map(|n| n.is_array()).unwrap_or(false) { continue; }
            match serde_json::from_value::<DecisionContent>(value) {
                Ok(content) => {
                    let _ = Decision::from(content);
                    compiled += 1;
                    println!("  ✓ {}", path.display());
                }
                Err(e) => failures.push((path, e.to_string())),
            }
        }
    }
    if !failures.is_empty() {
        eprintln!("\n{} ruleset(s) failed to compile:", failures.len());
        for (p, e) in &failures { eprintln!("  ✗ {}: {}", p.display(), e); }
        bail!("ruleset schema check failed");
    }
    println!("\n{compiled} ruleset(s) compiled successfully.");
    Ok(())
}

Auto-importer extension in services/canopy-rules/src/main.rs, added before the existing jurisdiction-scan loop:

// Scan federal JDM files first so jurisdiction overrides win on name collision.
// Parameter JSONs (fpl-2026.json, snap-allotments-2026.json, etc.) have no
// top-level `nodes` array and are skipped.
let federal_dir = std::env::var("CANOPY_FEDERAL_RULESETS_DIR")
    .unwrap_or_else(|_| "rulesets/federal".to_string());
if let Ok(entries) = std::fs::read_dir(&federal_dir) {
    for entry in entries.flatten() {
        let path = entry.path();
        if path.extension().is_some_and(|e| e == "json") {
            let raw = std::fs::read_to_string(&path)?;
            let content: serde_json::Value = serde_json::from_str(&raw)?;
            if !content.get("nodes").map(|n| n.is_array()).unwrap_or(false) {
                continue;
            }
            let name = content["name"]
                .as_str()
                .unwrap_or_else(|| path.file_stem().and_then(|s| s.to_str()).unwrap_or("unnamed"))
                .to_string();
            let description = content["description"].as_str().map(|s| s.to_string());
            store::upsert_rule_set_by_name(
                boot.db.inner(),
                &name,
                description.as_deref(),
                &content,
            )
            .await?;
            info!(name, path = %path.display(), "imported federal ruleset");
            imported_any = true;
        }
    }
}

Honest compile test in services/canopy-rules/tests/rules_test.rs: iterate rulesets/georgia/.json and rulesets/federal/.json, skip non-JDM files, for each call GET /v1/rule-sets?search={name} and assert the ruleset is present in the response. Because the test runs against the live devstack that auto-imports at startup, this is a runtime gate that complements the static xtask rules check gate.

Verify Step 1 alone:

  1. cargo xtask rules check runs locally — initially reports every file as broken (expected until Steps 3-6 land).

  2. cargo build -p canopy-rules -p xtask succeeds.

Step 2: Consolidate TANF/Medicaid onto shared canopy-rules-client

Why this step exists: The pre-existing ruleset_name typo in canopy-tanf and canopy-medicaid would silently break every TANF/Medicaid rules call even after the JDM rewrites. The right structural fix is consolidation: SNAP already uses the shared canopy-rules-client::RulesClient (services/canopy-snap/src/rules_client.rs:4) and that crate uses the correct field name. Deleting the duplicated bespoke clients also standardizes bearer-token forwarding behavior across services.

Files:

  • services/canopy-tanf/src/rules_client.rs — replace the bespoke TanfRulesClient with a thin wrapper around canopy_rules_client::RulesClient. Keep the TanfEligibilityInput/TanfEligibilityOutput/TanfBenefitInput/TanfBenefitOutput/WorkRequirementsInput/WorkRequirementsOutput types and the three high-level methods (evaluate_eligibility, calculate_benefit, evaluate_work_requirements); delete the inline HTTP client and the typo’d body construction. The high-level methods now call inner.evaluate(rule_set_name, "tanf", uuid, input_value) and serde-deserialize the output into the typed struct.

  • services/canopy-medicaid/src/rules_client.rs — same treatment for MedicaidRulesClient. Keep the MagiInput/NonMagiInput/ChipInput/HierarchyInput and corresponding output types; delete the inline HTTP client; route through canopy_rules_client::RulesClient.

  • services/canopy-tanf/Cargo.toml and services/canopy-medicaid/Cargo.toml — add canopy-rules-client = { workspace = true } if not already present.

  • services/canopy-tanf/src/main.rs and services/canopy-medicaid/src/main.rs — update the rules client construction to instantiate the new wrapper around canopy_rules_client::RulesClient::new(rules_url).

  • services/canopy-tanf/src/api/handlers.rs and services/canopy-medicaid/src/api/handlers.rs — confirm each handler that calls a rules method first calls rules.set_token(bearer_token).await so the caller’s JWT is forwarded. The shared client already supports this; the bespoke clients did too — verify the handler call sites still work after the swap.

Tests:

  • services/canopy-tanf/tests/rules_client_smoke_test.rs (NEW) — POST against the running canopy-tanf service for a determination, assert it does NOT 400 with "missing field rule_set_name`". Will still 500 until the JDM rewrites land in Step 4; mark with `#[ignore] and a comment "unignore after Step 4 lands".

  • Same kind of NEW smoke test for canopy-medicaid, unignored after Step 5 lands.

Verify Step 2:

  1. cargo build -p canopy-tanf -p canopy-medicaid succeeds.

  2. cargo nextest run -p canopy-tanf -p canopy-medicaid — existing tests (none of which exercise the rules client end-to-end) still pass.

  3. grep -n "ruleset_name" services/canopy-tanf/src services/canopy-medicaid/src returns nothing.

  4. grep -n "TanfRulesClient::new\|MedicaidRulesClient::new" services/canopy-*/src/main.rs shows the new wrapper construction.

Note: This step does NOT touch the determine.rs handlers or the broken JDM files. After this step lands, TANF determine will return 500 with a different error ("rule set not found: tanf-eligibility") instead of the current 400 with "missing field `rule_set_name`". That is progress — it means the request body now reaches canopy-rules.

Step 3: SNAP rulesets

Files:

  • rulesets/georgia/snap-eligibility.json — full rewrite

  • rulesets/georgia/snap-benefit-calculation.json — minimal valid stub

  • rulesets/federal/snap-alien-eligibility.json — full rewrite + change name field to georgia-snap-alien-eligibility to match the existing call site

  • services/canopy-snap/src/alien_eligibility.rs — remove the unwrap_or(false) / unwrap_or("no reason provided") / unwrap_or("7 CFR 273.4") masks at lines 100-115; replace with ok_or_else(|| ApiError::internal("missing eligible field", anyhow::anyhow!("output schema mismatch")))?

  • services/canopy-snap/src/alien_eligibility.rs — update doc comment at lines 80-81: remove the unimplemented "falling back to federal-…​" promise; replace with "Loaded from rulesets/federal/snap-alien-eligibility.json with name georgia-snap-alien-eligibility (see Plan: JDM Ruleset Schema Rewrite)."

  • services/canopy-snap/tests/snap_test.rs — remove the "broken JDM ruleset" skip in post_determine_returns_determination; strengthen its assertions; add 3 new strong-assertion tests

  • services/canopy-snap/tests/alien_eligibility_test.rs — NEW

Alien eligibility fallback decision: the Rust call site builds the ruleset name as format!("{jurisdiction}-snap-alien-eligibility")georgia-snap-alien-eligibility. The federal JDM file lives at rulesets/federal/snap-alien-eligibility.json and currently has name: "federal-snap-alien-eligibility". Three options were considered:

Option Pros Cons

(a) Change the federal file’s name to georgia-snap-alien-eligibility

Single file, single name, no Rust changes, matches Step 1 federal scan

Federal/jurisdiction split is cosmetic for this file

(b) Implement the documented fallback in Rust (try jurisdiction first, fall back on 404)

Honors original intent

Adds error-class checking complexity; no jurisdiction has actually authored an override; latent broken-by-default for any jurisdiction without a dedicated file

(c) Have Georgia author its own override file in rulesets/georgia/snap-alien-eligibility.json

Cleanest separation

Duplicate logic, drift risk

Decision: option (a). Single file in rulesets/federal/, named georgia-snap-alien-eligibility, loaded by the Step 1 federal scan, used directly by canopy-snap. If a future jurisdiction needs to override, it can author its own file in rulesets/{jurisdiction}/ and the Step 1 scan order (federal first, then jurisdiction) means the jurisdiction file wins via upsert_rule_set_by_name. The "fallback" semantics are then implicit in the load order.

snap-eligibility.json — node graph (preserved from current intent):

input → sw-categorical [hitPolicy=first, 3 statements]
  ├─ categorical_eligibility_type == "standard" → expr-standard-ce → expr-benefit
  ├─ categorical_eligibility_type == "bbce"     → expr-bbce-bypass-asset → dt-gross-income
  └─ "" (none)                                   → dt-gross-income
dt-gross-income      → dt-asset-test → expr-deductions → dt-net-income → expr-benefit → output
expr-bbce-bypass-asset (parallel arm of switch) → dt-gross-income
expr-standard-ce       (parallel arm of switch) → expr-benefit

dt-gross-income (1 input, 2 outputs, 2 rules):

  • inputs: [{id:"gi-income", type:"expression", field:"gross_monthly_income"}]

  • outputs: [{id:"gi-pass", type:"expression", field:"gross_income_test_passed"}, {id:"gi-basis", type:"expression", field:"gross_income_test_basis"}]

  • rules:

    • {_id:"r-gi-pass", _description:"At or below 130% FPL limit", "gi-income":"⇐ gross_income_limit", "gi-pass":"true", "gi-basis":"\"gross_income_pass\""}

    • {_id:"r-gi-fail", _description:"Above 130% FPL limit", "gi-income":"> gross_income_limit", "gi-pass":"false", "gi-basis":"\"gross_income_fail\""}

dt-asset-test (2 inputs, 2 outputs, 3 rules):

  • inputs: [{id:"at-bypass", type:"expression", field:"asset_test_passed"}, {id:"at-assets", type:"expression", field:"countable_assets"}]

  • outputs: [{id:"at-pass", type:"expression", field:"asset_test_passed"}, {id:"at-basis", type:"expression", field:"asset_test_basis"}]

  • rules: bypass row (if upstream already set the flag), pass row (⇐ asset_limit), fail row (> asset_limit).

dt-net-income (2 inputs, 2 outputs, 3 rules) — same structure with ni-bypass reading net_income_test_passed, ni-income reading the net_income produced by expr-deductions, comparing against net_income_limit.

expr-standard-ce (8 expressions): pre-set gross_income_test_passed=true, asset_test_passed=true, net_income_test_passed=true, all _basis fields to "categorical_standard_bypass", categorical_eligibility_basis="standard_categorical".

expr-bbce-bypass-asset (3 expressions): pre-set asset_test_passed=true, asset_test_basis="bbce_bypass", categorical_eligibility_basis="bbce". Downstream gross-income and net-income tests still run.

expr-deductions (14 expressions, ZEN arithmetic — translate every line from the current file to the flat {id, key, value} form). The expressions compute 6 mandatory SNAP deductions per 7 CFR 273.9(d): earned income deduction (20%), standard deduction, dependent care, child support paid, medical expense deduction (if elderly/disabled), excess shelter deduction (with SUA + homeless fallback + elderly/disabled uncapped), producing net_income = max(gross_monthly_income - total_deductions, 0).

expr-benefit (7 expressions): thirty_pct_net = round(net_income * 0.30, 2), base_allotment = max(max_allotment - $.thirty_pct_net, 0), eligible = gross_income_test_passed and asset_test_passed and net_income_test_passed, benefit_amount = if $.eligible then (if $.base_allotment < minimum_benefit and household_size ⇐ minimum_benefit_max_hh_size then minimum_benefit else $.base_allotment) else 0, benefit_unit, status, basis.

Edges (10 total) each get a unique id.

snap-benefit-calculation.json: 2-node stub (input → output) with contentType and one edge with id. Compile-only; no service calls it today.

snap-alien-eligibility.json (federal): Single decisionTableNode with 10 inputs (the AlienEligibilityInput fields at services/canopy-snap/src/alien_eligibility.rs:24-35) and 3 outputs (eligible, reason, citation). Rules encode the existing 7 CFR 273.4 categories: refugee/asylee always eligible, LPR with 5+ years qualified, military-connected, children under 18, disabled, victim of trafficking. Default rule returns false with reason "not_qualified_alien_or_no_exemption" and citation "7 CFR 273.4(a)".

Tests:

  • post_determine_returns_determination — strengthen the existing test (no longer skipped). Household with household_size: 3, gross_monthly_income: 1200, countable_assets: 1500, no elderly/disabled. Expected: assert_eq!(data["status"], "approved"), assert_eq!(data["eligible"], true), data["benefit_amount"] parseable as Decimal in range [1, max_allotment], data["benefit_unit"] == "monthly", data["basis"] == "snap_eligible", and all *_test_passed/*_test_basis fields set.

  • post_determine_categorical_eligibility_bypasses_tests (NEW) — is_categorically_eligible: true, expect data["asset_test_basis"] == "categorical_standard_bypass".

  • post_determine_over_gross_income_denies (NEW) — gross_monthly_income: 9999, expect assert_eq!(data["status"], "denied"), data["gross_income_test_passed"] == false, data["gross_income_test_basis"] == "gross_income_fail", benefit_amount parses as Decimal 0.

  • post_determine_minimum_benefit_path (NEW) — household_size: 1, low income. Call GET /v1/params?household_size=1 first to read minimum_benefit; assert data["benefit_amount"] equals that value. Do not hardcode the dollar amount.

  • tests/alien_eligibility_test.rs (NEW) — POST against the alien eligibility internal endpoint; assert all 3 output fields for the refugee, LPR-5-year, and unqualified-alien cases.

Verify Step 3:

  1. cargo xtask rules check shows 3 SNAP files passing (alongside any not-yet-rewritten failures).

  2. cargo nextest run -p canopy-snap passes including the 4 strong-assertion determine tests.

  3. canopy-rules logs rule set loaded name=georgia-snap-eligibility after cargo xtask dev refresh.

Step 4: TANF rulesets

Files:

  • rulesets/georgia/tanf-eligibility.json — full rewrite

  • rulesets/georgia/tanf-benefit-calculation.json — full rewrite

  • rulesets/georgia/tanf-work-requirements.json — full rewrite

  • services/canopy-tanf/tests/tanf_test.rs — remove the "broken JDM ruleset" skip; strengthen post_determine_returns_determination; add 4 new strong-assertion tests

tanf-eligibility.json — node graph (single decisionTableNode, preserved intent). Inputs map to TanfEligibilityInput at services/canopy-tanf/src/rules_client.rs:12-23. Outputs match TanfEligibilityOutput at lines 27-33. Rules (hitPolicy=first) preserved 1:1 from the current file: time-limit-exceeded, no-qualifying-deprivation, deprivation-not-verified, not-a-citizen, no-dependent-children (fixes the current file’s bug where this returned eligible=true), eligible. Cell expressions use ZEN syntax: "false"== false, ">= 48">= 48, "null"== null, "⇐ 0"⇐ 0, empty string → always-true wildcard.

tanf-benefit-calculation.json — node graph (single expressionNode, preserved intent). 11 expressions from the current file, each with a unique id. Computes boarder exclusion, earned income disregard, countable income, child support gap budgeting, benefit amount per PAMMS 1605 and 1645. Output fields per TanfBenefitOutput.

tanf-work-requirements.json — node graph (decisionTableNode + expressionNode, preserved intent). dt-exemption with 6 inputs and 5 outputs, 9 rules (under 18, over 59, disabled, infant <12mo, DV waiver, 3rd trimester, two-parent, single-parent-with-young-child, default single-parent). Followed by expr-activity with 5 expressions (core/non-core activity classification, hours_met calculation, compliance_status) per PAMMS 1820 and 45 CFR 261.31.

Tests:

  • post_determine_returns_determination — strengthen. household_size=3, dependent_children=2, deprivation_type="CSO", deprivation_verified=true. Expect status: "approved", eligible: true, non-empty signature, benefit_amount > 0.

  • post_determine_no_dependent_children_denies (NEW) — dependent_children: 0. Expect status: "denied", denial_reasons[0] contains "No dependent children".

  • post_determine_time_limit_exceeded_denies (NEW) — pre-create a time limit record with months_used: 60 via the canopy-tanf store helper, then POST. Expect denial_reasons[0] contains "Time limit".

  • post_determine_no_deprivation_denies (NEW) — deprivation_type: null. Expect denial_reasons[0] contains "No qualifying deprivation".

  • post_work_requirements_caretaker_exempt (NEW) — POST work-requirements internal endpoint with youngest_child_age_months: 6. Expect exempt: true, exemption_reason contains "infant".

Verify Step 4:

  1. cargo xtask rules check shows 3 TANF files passing.

  2. cargo nextest run -p canopy-tanf passes.

  3. POST /v1/determine returns 200 against devstack.

  4. Un-ignore the Step 2 smoke test.

Step 5: Medicaid rulesets + ADR-003 migration

Files:

  • rulesets/georgia/medicaid-magi.json — full rewrite encoding evaluate_magi_coa logic from services/canopy-medicaid/src/determine.rs:280-352

  • rulesets/georgia/medicaid-non-magi.json — full rewrite encoding evaluate_non_magi_coa from lines 383-419

  • rulesets/georgia/chip-eligibility.json — full rewrite encoding evaluate_chip_coa from lines 356-380

  • rulesets/georgia/medicaid-eligibility-hierarchy.json — full rewrite encoding the EE15 hierarchy logic

  • services/canopy-medicaid/src/determine.rs:

    • rename _rules: &MedicaidRulesClientrules: &MedicaidRulesClient (line 66)

    • replace the inline match coa.track at lines 130-132 with rules.evaluate_magi(…​).await, rules.evaluate_chip(…​).await, rules.evaluate_non_magi(…​).await

    • use rules.evaluate_hierarchy(…​).await to pick the assigned COA

    • delete evaluate_magi_coa (lines 280-352), evaluate_chip_coa (lines 356-380), evaluate_non_magi_coa (lines 383-419)

    • delete the #[cfg(test)] mod tests block at approximately lines 540-870 that exercises those functions

    • pre-populate the MagiInput / NonMagiInput / ChipInput / HierarchyInput structs from the existing thresholds: MedicaidThresholds so all FPL percentages and limits are passed as input fields

  • services/canopy-medicaid/src/rules_client.rs — extend the input types with threshold fields the rulesets need (pregnant_women_threshold_cents, child_0_1_threshold_cents, parent_caretaker_threshold_cents, pathways_threshold_cents, chip_lower_threshold_cents, chip_upper_threshold_cents)

  • services/canopy-medicaid/src/api/handlers.rs — extract bearer token from incoming request and call rules.set_token(token).await before the determine call (mirror canopy-tanf pattern)

  • services/canopy-medicaid/tests/medicaid_test.rs — add 8+ integration tests covering each COA boundary

Step 5 sub-ordering (apply in this order to avoid intermediate broken state):

  1. Rewrite the 4 Medicaid JDM files (in any order; independent).

  2. Run cargo xtask rules check — all 4 must compile.

  3. Extend MedicaidRulesClient input structs with the threshold fields the rulesets reference.

  4. Update services/canopy-medicaid/src/api/handlers.rs to call rules.set_token(bearer).await.

  5. Rewrite determine.rs to call the rules client (rename _rulesrules, add the orchestration: per-COA-track collect MAGI/CHIP/non-MAGI results, build eligible_coas list, call evaluate_hierarchy).

  6. Delete the inline evaluate_*_coa functions and their #[cfg(test)] mod tests.

  7. Run integration tests against devstack.

Doing 5 before 1-4 leaves determine.rs calling functions that don’t compile.

medicaid-magi.json node graph: Single dt-magi decision table (hitPolicy=first) with inputs mapping to MagiInput + threshold fields, and outputs matching MagiOutput. Rules encode each branch of evaluate_magi_coa:

Rule Condition

r-pw-not-pregnant

coa_name == "pregnant_women" and is_pregnant == false

r-pw-eligible

coa_name == "pregnant_women" and is_pregnant == true and net_magi ⇐ pw_threshold

r-pw-over-income

coa_name == "pregnant_women" and is_pregnant == true

r-c19-too-old

coa_name == "children_under_19" and applicant_age >= 19

r-c19-infant

coa_name == "children_under_19" and applicant_age < 1 and net_magi ⇐ c01_threshold

r-c19-young

coa_name == "children_under_19" and applicant_age < 6 and net_magi ⇐ c15_threshold

r-c19-school

coa_name == "children_under_19" and net_magi ⇐ c618_threshold

r-c19-over-income

coa_name == "children_under_19"

r-pc-not-parent

coa_name == "parent_caretaker" and (applicant_age < 19 or household_size ⇐ 1)

r-pc-eligible

coa_name == "parent_caretaker" and net_magi ⇐ pc_threshold

r-pc-over-income

coa_name == "parent_caretaker"

r-pathways-age

coa_name == "pathways" and (applicant_age < 19 or applicant_age > 64)

r-pathways-eligible

coa_name == "pathways" and net_magi ⇐ pathways_threshold

r-pathways-over-income

coa_name == "pathways"

r-foster-too-old

coa_name == "former_foster_care" and applicant_age >= 26

r-foster-unverified

coa_name == "former_foster_care"

r-default

(all empty — catch-all)

Each rule sets the corresponding output flag and denial reason. Add an expr-aggregate expressionNode after the table to populate fpl_percentage and applicable_fpl_threshold.

medicaid-non-magi.json node graph: Same pattern encoding evaluate_non_magi_coa. Rules: SSI Medicaid with disability_status == "ssi_recipient", QMB/SLMB/QI1 with age >= 65 or disability_status != null, AMN (stub "spenddown required"), Nursing Home (stub "LOC verification required"), default "not evaluable".

chip-eligibility.json node graph: dt-chip encoding evaluate_chip_coa: PeachCare with age < 19, income between chip_lower and chip_upper → eligible. Followed by expr-premium computing premium tier and monthly amount placeholders matching current Rust.

medicaid-eligibility-hierarchy.json node graph: dt-hierarchy (hitPolicy=first) with precedence-ordered rules: SSI → Pregnant Women → Children → Parent Caretaker → Pathways → CHIP → Non-MAGI ABD → AMN. Each rule checks eligible_coas contains "<coa>" and sets assigned_coa, assigned_track, rationale. Default rule emits assigned_coa: null, rationale: "no eligible coa".

Tests (all use assert_eq! / matches! against response JSON):

  • magi_parent_caretaker_eligible_at_30_pct_fpl — expect assigned_coa == "parent_caretaker", status == "approved"

  • magi_parent_caretaker_denied_at_36_pct_fpl — expect denial_reasons contains "income_over_35_pct_fpl"

  • magi_children_under_19_age_5_eligible — expect assigned_coa == "children_under_19"

  • magi_children_age_19_ineligible — expect denial_reasons contains "age_19_or_older"

  • magi_pregnant_women_at_220_pct_eligible — expect assigned_coa == "pregnant_women"

  • chip_age_18_at_200_pct_eligible — expect assigned_coa == "peachcare"

  • chip_age_18_at_248_pct_denied — expect denial_reasons contains "income_over_247_pct_fpl"

  • non_magi_ssi_recipient_eligible — expect assigned_coa == "ssi_medicaid"

  • hierarchy_picks_most_advantageous — applicant eligible for both parent_caretaker and PeachCare; expect assigned_coa == "parent_caretaker"

Verify Step 5:

  1. cargo xtask rules check shows 4 Medicaid files passing.

  2. cargo nextest run -p canopy-medicaid passes. The lib test count decreases by ~30 (removed inline-Rust unit tests) while the integration test count increases by ~9 (new HTTP-path tests).

  3. grep -n "evaluate_magi_coa\|evaluate_chip_coa\|evaluate_non_magi_coa\|_rules:" services/canopy-medicaid/src/determine.rs returns nothing.

  4. canopy-medicaid request logs show evaluate calls hitting canopy-rules over HTTP.

  5. Un-ignore the Step 2 smoke test.

Step 6: CAPS + WIC stub rulesets

Files:

  • rulesets/georgia/caps-eligibility.json — minimal valid zen 0.55 stub

  • rulesets/georgia/wic-eligibility.json — same

Each file becomes a 2-node inputNode → outputNode graph with contentType, a single edge with id, and a _comment describing the stub status. Compile-only; no service consumes them today. Roughly 30 lines of JSON each.

Verify Step 6: cargo xtask rules check shows the full inventory passing. Final count: 12 ruleset files compile cleanly (11 georgia + 1 federal-snap-alien-eligibility).

Step 7: CI gate + remove test skips

Files:

  • xtask/src/cmd/validate.rs — already calls rules::check() from Step 1; confirm it bails on failure (this step makes the gate enforcing now that every file compiles)

  • .gitlab-ci.yml — add a rules-check job at the lint/check stage that runs cargo xtask rules check. Fast (< 30s, no devstack required).

  • Audit and remove any remaining if resp.status == 500 { eprintln!(…​); return; } patterns in services/canopy-snap/tests/snap_test.rs, services/canopy-tanf/tests/tanf_test.rs, services/canopy-security/tests/security_test.rs, services/canopy-reporting/tests/reporting_test.rs.

Verify Step 7:

  1. cargo xtask validate runs end-to-end including rules check.

  2. CI pipeline has a rules-check stage that fails fast on any schema regression.

  3. grep -rn "broken JDM ruleset\|JWT issuer mismatch\|upstream service issue" services/*/tests/ returns zero results.

Step 8: End-to-end verification

  1. cargo xtask dev refresh --shared-db — bring up devstack with all changes

  2. docker compose logs canopy-rules | grep "rule set loaded" — confirm count ≥ 12 with zero failed to compile warnings

  3. cargo xtask rules check — exit 0

  4. cargo xtask test — full unit + integration battery passes

  5. cargo xtask validate — full pre-push gate passes

  6. cargo xtask policy audit — citation count unchanged from baseline (150/150 when this plan started)

  7. cargo nextest run -p canopy-snap -p canopy-tanf -p canopy-medicaid — every determine integration test passes with strong assertions, no skips

  8. Manual smoke: POST /v1/determine against canopy-snap with the Household A fixture below; confirm response matches assertions

Test fixture: SNAP Household A (math-checked)

Inputs:
  household_size: 3
  gross_monthly_income: 1200.00     # well below 130% FPL for HH=3
  gross_earned_income: 1200.00      # all from employment
  gross_unearned_income: 0
  countable_assets: 1500.00         # below asset limit
  has_elderly_disabled_member: false
  categorical_eligibility_type: ""  # none
  shelter_costs: 800.00
  medical_expenses: 0
  child_support_paid: 0
  dependent_care_total: 0
  is_homeless: false
  jurisdiction: "georgia"

Expected determination output (assuming FY2026 federal params + georgia jurisdiction.toml):
  status: "approved"
  eligible: true
  benefit_unit: "monthly"
  basis: "snap_eligible"
  gross_income_test_passed: true
  gross_income_test_basis: "gross_income_pass"
  asset_test_passed: true
  asset_test_basis: "asset_test_pass"
  net_income_test_passed: true
  net_income_test_basis: "net_income_pass"
  benefit_amount: > 0  # exact value depends on FY2026 params; assert range [1, max_allotment_for_3]

Verify these constants in the actual federal JSON files before hardcoding them in the test — if the federal params have moved on, the test fixture math will be slightly off and the test should read the params from GET /v1/params instead.

Sanity greps

# Ruleset schema gate
cargo xtask rules check                                                   # exit 0

# No leftover skips or anti-patterns
grep -rn "broken JDM ruleset\|JWT issuer mismatch\|upstream service issue" \
    services/*/tests/                                                     # zero
grep -n "_rules:\|evaluate_magi_coa\|evaluate_chip_coa\|evaluate_non_magi_coa" \
    services/canopy-medicaid/src/determine.rs                             # zero
grep -n "ruleset_name" services/canopy-tanf/src services/canopy-medicaid/src  # zero
grep -n "unwrap_or(false)\|unwrap_or(\"no reason" \
    services/canopy-snap/src/alien_eligibility.rs                         # zero

Files Touched

File Change

xtask/src/cmd/rules.rs

NEW — cargo xtask rules check subcommand

xtask/src/cmd/mod.rs

Add pub mod rules;

xtask/src/main.rs

Register Rules { Check } subcommand

xtask/src/cmd/validate.rs

Call rules::check() after fmt-check

xtask/Cargo.toml

Add zen-engine = { workspace = true }

services/canopy-rules/src/main.rs

Auto-importer scans rulesets/federal/ before rulesets/{jurisdiction}/

services/canopy-rules/tests/rules_test.rs

Add every_real_ruleset_compiles test

services/canopy-tanf/src/rules_client.rs

Step 2 — replace bespoke client with thin wrapper around canopy_rules_client::RulesClient; fixes pre-existing ruleset_name typo

services/canopy-tanf/Cargo.toml

Step 2 — add canopy-rules-client workspace dep

services/canopy-tanf/src/main.rs

Step 2 — wire new rules client constructor

services/canopy-tanf/tests/rules_client_smoke_test.rs

NEW — Step 2 smoke test (ignored until Step 4)

services/canopy-medicaid/src/rules_client.rs

Step 2 — replace bespoke client; Step 5 — extend input structs with threshold fields

services/canopy-medicaid/Cargo.toml

Step 2 — add canopy-rules-client workspace dep

services/canopy-medicaid/src/main.rs

Step 2 — wire new rules client constructor

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

Step 5 — add bearer token extraction + rules.set_token

services/canopy-medicaid/src/determine.rs

Step 5 — wire MedicaidRulesClient, delete inline evaluate_*_coa functions and their tests

services/canopy-medicaid/tests/medicaid_test.rs

Step 5 — 9 strong-assertion integration tests for MAGI/CHIP/non-MAGI/hierarchy paths

services/canopy-medicaid/tests/rules_client_smoke_test.rs

NEW — Step 2 smoke test (ignored until Step 5)

services/canopy-snap/src/alien_eligibility.rs

Step 3 — remove unwrap_or masks; update doc comment

services/canopy-snap/tests/snap_test.rs

Step 3 — remove "broken JDM ruleset" skip; strengthen existing test; add 3 new strong-assertion tests

services/canopy-snap/tests/alien_eligibility_test.rs

NEW — alien eligibility integration tests

services/canopy-tanf/tests/tanf_test.rs

Step 4 — remove "broken JDM ruleset" skip; strengthen existing test; add 4 new strong-assertion tests

rulesets/georgia/snap-eligibility.json

Full rewrite — preserve current intent in zen 0.55 schema

rulesets/georgia/snap-benefit-calculation.json

Minimal valid stub in zen 0.55 schema

rulesets/georgia/tanf-eligibility.json

Full rewrite (also fixes the "no dependent children returns eligible=true" bug in the current file)

rulesets/georgia/tanf-benefit-calculation.json

Full rewrite

rulesets/georgia/tanf-work-requirements.json

Full rewrite

rulesets/georgia/medicaid-magi.json

Full rewrite encoding evaluate_magi_coa

rulesets/georgia/medicaid-non-magi.json

Full rewrite encoding evaluate_non_magi_coa

rulesets/georgia/chip-eligibility.json

Full rewrite encoding evaluate_chip_coa

rulesets/georgia/medicaid-eligibility-hierarchy.json

Full rewrite encoding the EE15 precedence cascade

rulesets/georgia/caps-eligibility.json

Minimal valid stub

rulesets/georgia/wic-eligibility.json

Minimal valid stub

rulesets/federal/snap-alien-eligibility.json

Full rewrite encoding 7 CFR 273.4 categories; rename name field to georgia-snap-alien-eligibility

.gitlab-ci.yml

Add rules-check job at lint/check stage

Verification

  1. cargo nextest run --workspace --lib — unit tests pass (including the Medicaid lib-test count dropping by ~30 after Step 5 deletion)

  2. cargo xtask dev refresh --shared-db — bring up devstack with every rewrite

  3. cargo xtask rules check — every JDM ruleset compiles cleanly under zen-engine 0.55

  4. cargo nextest run --workspace — every integration test passes, no skips

  5. cargo xtask validate — full pre-push gate passes (now includes rules check)

  6. cargo xtask policy audit — citation count remains 150/150 (no regression)

  7. docker compose logs canopy-rules | grep -c "rule set loaded" — ≥ 12 with zero failed to compile warnings

Documentation Updates

  • .claude/docs/services.md — note that canopy-medicaid now calls canopy-rules via HTTP for all COA evaluation (was: inline Rust)

  • .claude/docs/testing.md — document the cargo xtask rules check gate

  • CHANGELOG.adoc — entry under == Unreleased: "Fix: rewrite all JDM ruleset files against zen-engine 0.55 schema; add cargo xtask rules check CI gate; migrate canopy-medicaid off inline Rust evaluators per ADR-003"

  • Update this plan’s Status table after each step

  • Mark Plan: Medicaid Eligibility Service "inline Rust evaluators" as resolved by this plan in its Status table

  • Update Roadmap — add an entry for this plan in the Phase 3 list

PAMMS Source References

  • SNAP gross income test (130% FPL): 7 CFR 273.9(a), PAMMS dfcs-snap/modules/snap/pages/3625.adoc

  • SNAP net income test (100% FPL): 7 CFR 273.10(c), PAMMS dfcs-snap/modules/snap/pages/3625.adoc

  • SNAP categorical eligibility (BBCE / standard): 7 CFR 273.2(j), PAMMS dfcs-snap/modules/snap/pages/3050.adoc

  • SNAP deductions: 7 CFR 273.9(d), PAMMS dfcs-snap/modules/snap/pages/3612.adoc through 3618.adoc

  • SNAP benefit allotment: 7 CFR 273.10(e), PAMMS dfcs-snap/modules/snap/pages/3645.adoc

  • SNAP minimum benefit (1- and 2-person households): 7 CFR 273.10(e)(2)(ii)©

  • SNAP alien eligibility: 7 CFR 273.4, PAMMS dfcs-snap/modules/snap/pages/3540.adoc

  • TANF eligibility (deprivation, time limits): 45 CFR 233 / 45 CFR 261, PAMMS dfcs-tanf/modules/tanf/pages/1100.adoc through 1395.adoc

  • TANF benefit (Family Maximum): PAMMS dfcs-tanf/modules/tanf/pages/1605.adoc, 1645.adoc

  • TANF work participation: 45 CFR 261.31, PAMMS dfcs-tanf/modules/tanf/pages/1349.adoc, 1820.adoc

  • Medicaid MAGI (parent/caretaker, children, pregnant women, pathways): PAMMS dfcs-medicaid/modules/medicaid/pages/2669.adoc, 2052.adoc

  • Medicaid non-MAGI (SSI, ABD, QMB/SLMB/QI1, AMN, Nursing Home): PAMMS dfcs-medicaid/modules/medicaid/pages/2700.adoc through 2900.adoc

  • CHIP / PeachCare (134%-247% FPL): PAMMS dfcs-medicaid/modules/medicaid/pages/2182.adoc

  • EE15 hierarchy (most advantageous COA selection): PAMMS dfcs-medicaid/modules/medicaid/pages/2052.adoc

Every rewritten JDM file MUST preserve the _comment and _description strings from its predecessor, byte-for-byte where possible, so ADR-011 citation traceability remains intact and cargo xtask policy audit continues to pass.

Errata

Step 2.5: canopy-rules refactor to zen-engine SDK best practices

Deviation: Step 2.5 was inserted mid-plan, between the originally-planned Step 2 (rules-client consolidation) and Step 3 (SNAP ruleset rewrite). It is a ~500-line refactor of services/canopy-rules/src/{engine,api,main,store}.rs + a new migration that drops the rule_sets table + a 9-file-touched commit touching the seed tool.

Why the deviation was legitimate: While debugging Step 3’s SNAP rewrite, two things became clear:

  1. The existing canopy-rules/src/engine.rs reinvented zen-engine’s loader caching pattern (custom HashMap<String, Arc<Decision>> cache) and its !Send future handling (bespoke std::thread + mpsc channel). Both have documented, more idiomatic replacements in the zen-engine 0.55 Rust SDK (FilesystemLoader + CachedLoader for loading, LocalPoolHandle from tokio-util for !Send futures).

  2. The custom evaluation path did NOT support evaluate_with_opts(EvaluationOptions { trace: true, .. }). Without trace output, debugging broken ZEN expressions in the SNAP ruleset was a guess-and-check loop that was burning hours. With trace output it takes seconds.

Since the existing code path wasn’t actually functional (no integration test had ever successfully evaluated a real ruleset — see the Context section), there was nothing to lose by moving to the SDK-recommended patterns before finishing the SNAP rewrite rather than after. The user ("the code isn’t working yet so we’re not losing much in the refactor to follow best practices — do it now") explicitly approved the mid-plan insertion.

What it delivers:

  • NamedFilesystemLoader in services/canopy-rules/src/engine.rs — custom DecisionLoader impl that maps logical ruleset names (the name field inside each JDM file) to on-disk paths. Scans rulesets/federal/ then rulesets/{jurisdiction}/ at startup, skips non-JDM parameter files by checking for a top-level nodes array.

  • Wrapped in zen-engine’s CachedLoader for memoization per the SDK doc’s keep_in_memory: true equivalent.

  • LocalPoolHandle::new(1) from tokio-util for pinned !Send evaluation futures. The pinned closure serializes the zen response to plain JSON before returning so only Send types cross the thread boundary.

  • ?trace=true query parameter on POST /v1/evaluate. Response shape is now {output, duration_ms, trace?}. Verified against the SNAP ruleset during Step 3 debugging — invaluable for inspecting broken expression cells.

  • Error handling matches directly on EvaluationError::LoaderError(LoaderError::NotFound(_)) instead of string-matching the outer Display (which only produces "Loader error" and hides the inner variant).

  • Drops POST/PUT/DELETE /v1/rule-sets. Rulesets are filesystem-backed per ADR-003 — there is no runtime mutation path. GET /v1/rule-sets (list) and the new GET /v1/rule-sets/{name} (read by logical name) are retained.

  • Drops the rule_sets DB table via migration 20260412000000_drop_rule_sets_table.sql. It was a pre-loader cache that was never authoritative after the auto-import landed. The rule_evaluations audit table stays.

  • Drops ~780 lines of bespoke code (net -286 after the refactor adds).

Test impact:

  • 10/10 canopy-rules tests pass against devstack, including the strengthened every_real_ruleset_compiles runtime gate which exercises all 12 on-disk JDM files via the real loader path.

  • Eight tests that depended on POST /v1/rule-sets creation were deleted. New tests cover the filesystem-backed listing, get-by-name, trace, and not-found paths.

What Step 3 still owes: the original SNAP rewrite deliverables (3 new strong-assertion integration tests + tests/alien_eligibility_test.rs). Those land in the Step 3 commit sequence immediately after this errata.

Step 3 completion notes and follow-ups

Step 3 landed in commit 2f1df8b on fix/jdm-ruleset-rewrite after Step 2.5’s refactor made debugging tractable. Full summary:

  • rulesets/georgia/snap-eligibility.json — full rewrite per the schema delta table in the Context section. Three concrete zen 0.55-specific fixes beyond the schema delta were discovered during debugging and documented here for the TANF / Medicaid / CHIP rewrites in Steps 4-5:

    1. passThrough: true on every transform node. Without it, a decision-table or expression-node output replaces the input — so downstream expressions lose access to earlier fields. This is the TransformAttributes::pass_through flag on DecisionTableContent and ExpressionNodeContent (default false). Every non-trivial graph in Steps 4-5 needs this on each transform node.

    2. Ternary a ? b : c is the only conditional syntax. ZEN does not support if/then/else. Every conditional expression must use ?:. Nested ternaries work: cond1 ? val1 : (cond2 ? val2 : val3).

    3. max([a, b]) / min([a, b]) take a single array argument. The SDK doc’s brief reference to max(a, b) variadic form does not match zen-expression 0.55’s actual implementation — use the array form.

  • rulesets/georgia/snap-benefit-calculation.json — rewritten as a minimal 2-node stub since no service calls it and the real benefit math lives in expr-benefit inside georgia-snap-eligibility.

  • rulesets/federal/snap-alien-eligibility.jsonname field renamed from federal-snap-alien-eligibility to georgia-snap-alien-eligibility (option (a) decision from the Step 3 table). The file’s content was already correct zen 0.55 schema.

  • Latent graph bug fixed. The original expr-standard-ce → expr-benefit edge skipped expr-deductions, leaving net_income undefined when expr-benefit computed round(net_income * 0.30, 2). Fixed by routing expr-standard-ce → expr-deductions → dt-net-income (bypass row) → expr-benefit so net_income is always computed. This was never caught before because no test had ever reached expr-benefit in the standard-CE arm.

  • Decimal → JSON number conversion. rust_decimal::Decimal serializes as a JSON string by default, which would make ZEN expressions like 0.20 * gross_earned_income fail on type mismatch. services/canopy-snap/src/determine.rs now converts every Decimal in rules_input to a bare JSON number via to_f64. f64’s 52-bit mantissa exactly represents any SNAP dollar amount under $1B, which is adequate precision. The same conversion pattern must be applied in Step 4 (canopy-tanf determine handler) and Step 5 (canopy-medicaid determine handler).

  • ?trace=true unblocked the debugging loop. Landing the trace support in Step 2.5 turned a multi-hour guess-and-check cycle on the ZEN expression syntax into a minutes-long iteration. Every subsequent JDM rewrite should lean on cargo xtask rules check for static schema validation AND curl -X POST /v1/evaluate?trace=true against the live service for runtime trace.

  • unwrap_or masks removed. services/canopy-snap/src/alien_eligibility.rs previously used unwrap_or(false), unwrap_or("no reason provided"), and unwrap_or("7 CFR 273.4") to mask output schema mismatches. Replaced with hard ok_or_else(|| ApiError::internal(…​)) returns. Any future ruleset output drift now surfaces as a clean 500 instead of a silent wrong result.

Step 3 follow-up: wire alien_eligibility::evaluate() into determine.rs. The alien_eligibility module in canopy-snap declares an evaluate() function that POSTs to the rules engine, but no code path in canopy-snap/src/determine.rs ever calls it. The function has been dead since the module was introduced. For now, the integration test in services/canopy-snap/tests/alien_eligibility_test.rs POSTs directly to canopy-rules /v1/evaluate to prove the ruleset works — but the SNAP determine flow does not actually check alien eligibility yet. This is a separate follow-up that should:

  1. Call alien_eligibility::build_input from inside determine() for each non-citizen household member.

  2. Call alien_eligibility::evaluate with the assembled input.

  3. If any member’s result is eligible: false, exclude them from the household size used in the budget calculation (per 7 CFR 273.4(c)) OR deny the household entirely if appropriate.

Filed as a follow-up issue outside this plan.

Key lessons for Steps 4-5

The SNAP rewrite surfaced findings that apply directly to the TANF and Medicaid rewrites in the following steps. An implementer picking up Step 4 or 5 should read the Step 3 completion notes above before writing any JDM — the three concrete zen 0.55 gotchas (passThrough, ternary, max([…​]) array form) are not obvious from the SDK doc and cost hours to discover the first time.

Step 4 completion notes and follow-ups

Step 4 landed in commit 685bb32 on fix/jdm-ruleset-rewrite. All three TANF rulesets now compile and evaluate end-to-end. Key findings and follow-ups:

  • Another latent bug fixed. The original tanf-eligibility.json had an r-no-children rule with all-empty conditions that always matched as a fallback AND output eligible: true — so a household with 0 dependent children would have been approved. The rewrite fixes this: c-children: "⇐ 0" → deny with "No dependent children" in the denial reasons. Documented in post_determine_no_dependent_children_denies.

  • TanfBenefitInput field rename. The old state_max_benefit: Decimal and payment_standard: Decimal fields were always passed as Decimal::ZERO by determine.rs with the comment "loaded by rules engine from jurisdiction config" — except the rules engine has no way to read jurisdiction config, so the whole benefit math was a no-op stub. Replaced with effective_date: NaiveDate and expiration_date: NaiveDate, which determine.rs now computes (today → +6 months) and the ruleset passThrough`s to its output unchanged. The ruleset’s new `family_maximum ternary is the actual math per PAMMS 1810.

  • Decimal serialization asymmetry discovered and worked around. rust_decimal::Decimal’s default serde impl (under the workspace’s `serde-str feature) deserializes from strings and serializes as strings. But ZEN arithmetic needs numbers on input, and the JDM emits numbers on output. Fix: local serialize_decimal_as_number helper for TanfBenefitInput.countable_income and every TanfEligibilityInput Decimal field (converts via to_f64); local deserialize_decimal_from_number for TanfBenefitOutput.benefit_amount (accepts JSON numbers OR strings). Same pattern will be needed in Step 5 for MagiInput / NonMagiInput / ChipInput / HierarchyInput on canopy-medicaid.

  • PAMMS 1810 family-max table hardcoded in the ruleset. The family maximum by household size is currently embedded as a ternary cascade inside tanf-benefit-calculation.json:

    household_size <= 1 ? 235 : (household_size == 2 ? 280 : (household_size == 3 ? 330 : ...))

    A follow-up plan (tracked as part of federal-parameter-completion) should move this table into jurisdiction.toml under a new [tanf.family_maximum] section and have canopy-tanf inject the values into TanfBenefitInput from its jurisdiction params at startup. This will let non-Georgia jurisdictions override without touching the JDM file.

  • Real TANF benefit math (boarder exclusion, earned-income disregard, child-support gap budgeting per PAMMS 1605/1645) is out of scope. The Step 4 rewrite implements the minimum viable math to produce a positive benefit for approved cases. Full PAMMS 1605/1645 alignment is tracked by the existing tanf-pamms-alignment plan and should compose cleanly with this rewrite — add new expression nodes before expr-benefit that compute countable_income from raw income + deductions.

  • canopy-tanf does not expose a work-requirements evaluation endpoint. The new work_requirements_caretaker_of_infant_is_exempt integration test POSTs directly to canopy-rules /v1/evaluate against the tanf-work-requirements ruleset, mirroring the Step 3 alien-eligibility test pattern. A follow-up should add a POST /v1/work-requirements/evaluate handler to canopy-tanf that wraps the rules client call — similar to how canopy-snap’s post_determine wraps its own rules evaluation.

  • Step 2 smoke test deleted. services/canopy-tanf/tests/rules_client_smoke_test.rs was added in Step 2 as a stopgap check that the consolidated rules client didn’t 400 on the rule_set_name typo. Now that the full determine path works end-to-end with strong assertions, the smoke test is redundant. The equivalent canopy-medicaid smoke test stays in place until Step 5 lands.

Step 4 income-test retrofit (pre-Step-5 cleanup)

During the pre-Step-5 audit the Step 4 TANF eligibility ruleset was found to have hardcoded gross_income_test_passed=true / net_income_test_passed=true on every rule, meaning the gross/net income gates were not actually enforced. This was an honest gap in the Step 4 completion, not a deviation from the plan — the plan called for PAMMS 1501 income tests but the initial Step 4 implementation ran out of runway. Fixed in the cleanup phase before Step 5:

  • TanfParameterTable extended to load [tanf.earned_income].disregard_amount_cents (PAMMS 1615 flat $250 disregard per employed individual). #[allow(dead_code)] removed from gross_income_ceiling and standard_of_need — both are now used.

  • determine.rs computes real net income. Previously let net_income = gross_income; (the comment said "deductions applied by rules engine" but the rules engine had nothing to deduct with). Now: split income per-person by earned type (wages / self_employment / self_employment_net), sum each earner’s gross earned, apply min(earner_gross, flat_disregard) per PAMMS 1615, subtract the total from gross income, clamp to zero.

  • TanfEligibilityInput extended with gross_income_ceiling and standard_of_need fields injected from the parameter table per ADR-011 — never hardcoded in the ruleset.

  • tanf-eligibility.json now tests income. Two new rules: r-gross-over compares gross_income > gross_income_ceiling → denies with PAMMS 1501 gross ceiling reason; r-net-over compares net_income > standard_of_need → denies with PAMMS 1501 SON reason. Both rules set the corresponding *_pass output field to false so downstream consumers can surface which gate failed.

  • Decimal serde helpers moved to shared crate. canopy-rules-client::decimal_serde (NEW) owns serialize_as_number / deserialize_from_number / serialize_opt_as_number with 8 unit tests. canopy-tanf’s local serialize_decimal_as_number / deserialize_decimal_from_number functions deleted. Step 5 (canopy-medicaid) will consume these shared helpers too — avoids triplicating the same workaround for `rust_decimal’s serde-str asymmetry.

Verification: 52/52 canopy-tanf tests pass including new earned_income_disregard_is_250 unit test and all existing determine integration tests; 10/10 canopy-rules-client tests pass; cargo xtask rules check shows tanf-eligibility.json compiles under zen 0.55.

Potential improvements (Step 4 follow-ups)

  • Earned income type matching hardcoded to strings. determine.rs uses matches!(item.income_type.as_str(), "wages" | "self_employment" | "self_employment_net") to decide what counts as earned. Should migrate to canopy_reference::IncomeType::is_earned() once that method exists — the enum variants are already defined. Tracked as a micro-cleanup; not a blocker for Step 5.

  • Per-earner disregard vs. per-AU disregard. PAMMS 1615 says the disregard is "per employed individual" which is what we implement, but some edge cases (e.g. minors earning wages) may be excluded from the disregard entirely under PAMMS 1611. Full PAMMS 1605/1611/1615 alignment stays in the tanf-pamms-alignment plan as originally scoped.

Step 5 + 6 completion notes

Steps 5 (Medicaid ADR-003 migration) and 6 (CAPS + WIC stubs) landed together. cargo xtask rules check now shows 12 compiled, 0 failed — every JDM file in the project compiles under zen-engine 0.55.

  • Inline Rust evaluators deleted. evaluate_magi_coa, evaluate_chip_coa, evaluate_non_magi_coa (plus load_thresholds_from_jurisdiction and MedicaidThresholds) removed from determine.rs. The ~20 unit tests exercising those functions are replaced by 7 HTTP integration tests that verify the full canopy-medicaid → canopy-rules → JDM evaluation → response path.

  • MedicaidParameterTable (NEW). Mirrors the TanfParameterTable pattern: loads rulesets/federal/fpl-2026.json (HH-indexed monthly FPL) + rulesets/{jurisdiction}/jurisdiction.toml [medicaid] (percentage thresholds) once at startup. Injected via Extension<Arc<MedicaidParameterTable>>. Replaces the per-request load_thresholds_from_jurisdiction that did filesystem I/O with silent fallback defaults.

  • Hierarchy ruleset uses parenthesized ternaries. The medicaid-eligibility-hierarchy.json expression node first computes 12 some(eligible_coas, # == "coa_code") booleans, then walks the PAMMS 2052 priority order via a deeply-nested ternary chain with explicit parenthesization. An initial version had unbalanced parentheses (11 open, 12 close) which produced a generic "Failed to evaluate expression" error at runtime — zen-engine’s error message does not surface the parse error details. Root-caused and fixed by counting parens; added hierarchy_jdm_evaluates_in_process unit test that evaluates the JDM in-process to catch this class of error in CI.

  • Parallel rules evaluation via tokio::try_join!. determine.rs fires evaluate_magi, evaluate_non_magi, and evaluate_chip concurrently since they’re independent HTTP calls. Then joins results and maps per-COA booleans onto the CMD cascade.

  • Former foster care COA stubbed. The MAGI ruleset outputs former_foster_care_eligible: false (same as the deleted inline Rust). Foster care verification lives outside the ruleset; the full implementation is tracked by medicaid-implementation.adoc.

  • Non-MAGI ABD COAs largely stubbed. SSI auto-qualify works; QMB/SLMB/QI-1/AMN return false with stub denial reasons matching the deleted inline Rust. Full ABD expansion tracked by medicaid-implementation.adoc.

Verification: 807/807 workspace tests pass. 61/61 canopy-medicaid (7 new integration + 5 params unit + existing). cargo xtask rules check: 12/12 compiled. Integration tests confirm pregnant_women, children_under_19, pathways, peachcare, and ssi_medicaid assignments end-to-end through canopy-rules HTTP.

Post-completion review fixes

Three subagent code reviews (one per step-group) identified the following issues, all addressed in a single follow-up commit:

  • Token forwarding race condition (pre-existing, all 3 services). RulesClient::set_token() mutated shared Arc<RwLock<Option<String>>> state. Under concurrent requests, token B could overwrite token A before A’s evaluate calls fired — wrong audit identity. Fix: evaluate() now takes token: Option<&str> per-call; set_token() deleted; tokio dep dropped from canopy-rules-client.

  • Missing TANF income denial test coverage. The r-gross-over and r-net-over rules added in Step 4 had zero integration test coverage. Added post_determine_gross_income_over_ceiling_denies and post_determine_net_income_over_son_denies.

  • Misnamed Medicaid test. post_determine_adult_denied_when_not_parent_caretaker actually asserted Pathways approval. Renamed to post_determine_adult_under_100_pct_fpl_gets_pathways.

  • Unnecessary hierarchy HTTP call on full denial. When eligible_codes is empty, the hierarchy ruleset call was a no-op round-trip. Added short-circuit: if eligible_codes.is_empty() { return denied }.

  • Redundant cargo build -p xtask in CI. cargo xtask rules check already triggers the build implicitly. Removed the extra line.

Verification: 810/810 workspace tests pass after fixes.

Edit this page · default