Plan: JDM Ruleset Schema Rewrite
On this page
- Status
- Context
- Scope
- Design
- Steps
- Step 1: Foundation —
cargo xtask rules check, federal auto-importer scan, honest compile test - Step 2: Consolidate TANF/Medicaid onto shared
canopy-rules-client - Step 3: SNAP rulesets
- Step 4: TANF rulesets
- Step 5: Medicaid rulesets + ADR-003 migration
- Step 6: CAPS + WIC stub rulesets
- Step 7: CI gate + remove test skips
- Step 8: End-to-end verification
- Step 1: Foundation —
- Files Touched
- Verification
- Documentation Updates
- PAMMS Source References
- Errata
Status
| Step | Description | Status |
|---|---|---|
0 |
Branch + this plan-of-record |
Done (2026-04-12) |
1 |
Foundation: |
Done (2026-04-12) |
2 |
Consolidate canopy-tanf + canopy-medicaid onto shared |
Done (2026-04-12) |
2.5 |
Refactor canopy-rules to adopt zen-engine |
Done (2026-04-12) |
3 |
Rewrite SNAP rulesets (snap-eligibility, snap-benefit-calculation, snap-alien-eligibility); remove |
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 |
Done (2026-04-12) |
6 |
Rewrite CAPS + WIC stub rulesets as minimal valid zen 0.55 graphs |
Done (2026-04-12) |
7 |
Wire |
Done (2026-04-12) |
8 |
End-to-end verification: |
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 |
|
needs |
|
|
needs |
|
|
needs |
|
|
needs |
|
|
needs |
|
verbose |
flat |
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:
-
ruleset_namefield name typo (TANF + Medicaid). canopy-tanf and canopy-medicaid each ship a bespokerules_client.rsthat POSTs to/v1/evaluatewith body fieldruleset_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 asrule_set_name. Result: every TANF and Medicaid rules call returns 400 ("missing fieldrule_set_name`") — even after the JDM files are fixed. SNAP avoids the bug because it re-exports the shared `canopy-rules-clientcrate (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 sharedcanopy-rules-client::RulesClient. -
alien_eligibility fallback never implemented. The doc comment at
services/canopy-snap/src/alien_eligibility.rs:80-81promises "calls{jurisdiction}-snap-alien-eligibilityif it exists, falling back tofederal-snap-alien-eligibility`" but the code at lines 87-98 only calls the jurisdiction-prefixed name with no fallback. The federal file’s `nameisfederal-snap-alien-eligibilityand there is nogeorgia-snap-alien-eligibilityfile. Result: every alien eligibility call 404s. Fix: in Step 3 the federal file’snamefield changes togeorgia-snap-alien-eligibilityAND the auto-importer scansrulesets/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. -
Soft-fallback masks in alien_eligibility result parsing. Lines 100-115 use
unwrap_or(false),unwrap_or("no reason provided"), andunwrap_or("7 CFR 273.4")which silently mask any output schema mismatch. After Step 3 these are replaced with hardok_or_elsereturningApiError::internalso 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/.jsonagainst 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 inrulesets/federal/; the rest are parameter JSONs loaded directly byparams.rsand 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-levelnodesarray). -
Add
cargo xtask rules checkthat compiles every JDM file underrulesets/viazen_engine::Decision::from(DecisionContent)and exits non-zero on any failure. -
Wire
xtask rules checkintocargo xtask validateand into.gitlab-ci.ymlas a fast lint-stage job. -
Migrate canopy-medicaid off the inline Rust evaluators per ADR-003: switch
services/canopy-medicaid/src/determine.rs:130-132to callMedicaidRulesClient::evaluate_magi/evaluate_chip/evaluate_non_magi/evaluate_hierarchy, deleteevaluate_magi_coa/evaluate_chip_coa/evaluate_non_magi_coaand 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, nounwrap_or(false)masks. -
Remove the "broken JDM ruleset" skips in
canopy-snap/tests/snap_test.rs::post_determine_returns_determinationandcanopy-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. Noassert!(… .is_some()), nounwrap_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
Confirmed against https://github.com/gorules/zen/tree/master/test-data/graphs/aml.json:
-
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}wherekeyis the output field name andvalueis the ZEN expression. References to prior expressions in the same node use$.<key>. -
Switch node statements:
{id, condition}whereconditionis 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 |
|---|---|---|
|
|
|
|
(currently unused) |
minimal compile-only stub |
|
|
|
|
|
|
|
|
|
|
|
|
|
new in Step 5 (replaces inline |
|
|
new in Step 5 (replaces inline |
|
|
new in Step 5 (replaces inline |
|
|
new in Step 5 (EE15 cascade) |
|
|
(no service handler yet) |
minimal compile-only stub |
|
(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 checkgate 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_nametypo 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— addzen-engine = { workspace = true } -
xtask/src/cmd/rules.rs— NEW -
xtask/src/cmd/mod.rs—pub mod rules; -
xtask/src/main.rs— registerRules { Check }subcommand -
xtask/src/cmd/validate.rs— callrules::check()aftercargo fmt --checkand beforecargo 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— addevery_real_ruleset_compilestest
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:
-
cargo xtask rules checkruns locally — initially reports every file as broken (expected until Steps 3-6 land). -
cargo build -p canopy-rules -p xtasksucceeds.
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 bespokeTanfRulesClientwith a thin wrapper aroundcanopy_rules_client::RulesClient. Keep theTanfEligibilityInput/TanfEligibilityOutput/TanfBenefitInput/TanfBenefitOutput/WorkRequirementsInput/WorkRequirementsOutputtypes 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 callinner.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 forMedicaidRulesClient. Keep theMagiInput/NonMagiInput/ChipInput/HierarchyInputand corresponding output types; delete the inline HTTP client; route throughcanopy_rules_client::RulesClient. -
services/canopy-tanf/Cargo.tomlandservices/canopy-medicaid/Cargo.toml— addcanopy-rules-client = { workspace = true }if not already present. -
services/canopy-tanf/src/main.rsandservices/canopy-medicaid/src/main.rs— update the rules client construction to instantiate the new wrapper aroundcanopy_rules_client::RulesClient::new(rules_url). -
services/canopy-tanf/src/api/handlers.rsandservices/canopy-medicaid/src/api/handlers.rs— confirm each handler that calls a rules method first callsrules.set_token(bearer_token).awaitso 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 fieldrule_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:
-
cargo build -p canopy-tanf -p canopy-medicaidsucceeds. -
cargo nextest run -p canopy-tanf -p canopy-medicaid— existing tests (none of which exercise the rules client end-to-end) still pass. -
grep -n "ruleset_name" services/canopy-tanf/src services/canopy-medicaid/srcreturns nothing. -
grep -n "TanfRulesClient::new\|MedicaidRulesClient::new" services/canopy-*/src/main.rsshows 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 + changenamefield togeorgia-snap-alien-eligibilityto match the existing call site -
services/canopy-snap/src/alien_eligibility.rs— remove theunwrap_or(false)/unwrap_or("no reason provided")/unwrap_or("7 CFR 273.4")masks at lines 100-115; replace withok_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 fromrulesets/federal/snap-alien-eligibility.jsonwith namegeorgia-snap-alien-eligibility(see Plan: JDM Ruleset Schema Rewrite)." -
services/canopy-snap/tests/snap_test.rs— remove the "broken JDM ruleset" skip inpost_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 |
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 |
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 withhousehold_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_basisfields set. -
post_determine_categorical_eligibility_bypasses_tests(NEW) —is_categorically_eligible: true, expectdata["asset_test_basis"] == "categorical_standard_bypass". -
post_determine_over_gross_income_denies(NEW) —gross_monthly_income: 9999, expectassert_eq!(data["status"], "denied"),data["gross_income_test_passed"] == false,data["gross_income_test_basis"] == "gross_income_fail",benefit_amountparses as Decimal0. -
post_determine_minimum_benefit_path(NEW) —household_size: 1, low income. CallGET /v1/params?household_size=1first to readminimum_benefit; assertdata["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:
-
cargo xtask rules checkshows 3 SNAP files passing (alongside any not-yet-rewritten failures). -
cargo nextest run -p canopy-snappasses including the 4 strong-assertion determine tests. -
canopy-rules logs
rule set loaded name=georgia-snap-eligibilityaftercargo 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; strengthenpost_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. Expectstatus: "approved",eligible: true, non-emptysignature,benefit_amount > 0. -
post_determine_no_dependent_children_denies(NEW) —dependent_children: 0. Expectstatus: "denied",denial_reasons[0]contains"No dependent children". -
post_determine_time_limit_exceeded_denies(NEW) — pre-create a time limit record withmonths_used: 60via the canopy-tanf store helper, then POST. Expectdenial_reasons[0]contains"Time limit". -
post_determine_no_deprivation_denies(NEW) —deprivation_type: null. Expectdenial_reasons[0]contains"No qualifying deprivation". -
post_work_requirements_caretaker_exempt(NEW) — POST work-requirements internal endpoint withyoungest_child_age_months: 6. Expectexempt: true,exemption_reasoncontains"infant".
Verify Step 4:
-
cargo xtask rules checkshows 3 TANF files passing. -
cargo nextest run -p canopy-tanfpasses. -
POST
/v1/determinereturns 200 against devstack. -
Un-ignore the Step 2 smoke test.
Step 5: Medicaid rulesets + ADR-003 migration
Files:
-
rulesets/georgia/medicaid-magi.json— full rewrite encodingevaluate_magi_coalogic fromservices/canopy-medicaid/src/determine.rs:280-352 -
rulesets/georgia/medicaid-non-magi.json— full rewrite encodingevaluate_non_magi_coafrom lines 383-419 -
rulesets/georgia/chip-eligibility.json— full rewrite encodingevaluate_chip_coafrom lines 356-380 -
rulesets/georgia/medicaid-eligibility-hierarchy.json— full rewrite encoding the EE15 hierarchy logic -
services/canopy-medicaid/src/determine.rs:-
rename
_rules: &MedicaidRulesClient→rules: &MedicaidRulesClient(line 66) -
replace the inline
match coa.trackat lines 130-132 withrules.evaluate_magi(…).await,rules.evaluate_chip(…).await,rules.evaluate_non_magi(…).await -
use
rules.evaluate_hierarchy(…).awaitto 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 testsblock at approximately lines 540-870 that exercises those functions -
pre-populate the
MagiInput/NonMagiInput/ChipInput/HierarchyInputstructs from the existingthresholds: MedicaidThresholdsso 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 callrules.set_token(token).awaitbefore 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):
-
Rewrite the 4 Medicaid JDM files (in any order; independent).
-
Run
cargo xtask rules check— all 4 must compile. -
Extend
MedicaidRulesClientinput structs with the threshold fields the rulesets reference. -
Update
services/canopy-medicaid/src/api/handlers.rsto callrules.set_token(bearer).await. -
Rewrite
determine.rsto call the rules client (rename_rules→rules, add the orchestration: per-COA-track collect MAGI/CHIP/non-MAGI results, buildeligible_coaslist, callevaluate_hierarchy). -
Delete the inline
evaluate_*_coafunctions and their#[cfg(test)] mod tests. -
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 |
|
r-pw-eligible |
|
r-pw-over-income |
|
r-c19-too-old |
|
r-c19-infant |
|
r-c19-young |
|
r-c19-school |
|
r-c19-over-income |
|
r-pc-not-parent |
|
r-pc-eligible |
|
r-pc-over-income |
|
r-pathways-age |
|
r-pathways-eligible |
|
r-pathways-over-income |
|
r-foster-too-old |
|
r-foster-unverified |
|
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— expectassigned_coa == "parent_caretaker",status == "approved" -
magi_parent_caretaker_denied_at_36_pct_fpl— expectdenial_reasonscontains"income_over_35_pct_fpl" -
magi_children_under_19_age_5_eligible— expectassigned_coa == "children_under_19" -
magi_children_age_19_ineligible— expectdenial_reasonscontains"age_19_or_older" -
magi_pregnant_women_at_220_pct_eligible— expectassigned_coa == "pregnant_women" -
chip_age_18_at_200_pct_eligible— expectassigned_coa == "peachcare" -
chip_age_18_at_248_pct_denied— expectdenial_reasonscontains"income_over_247_pct_fpl" -
non_magi_ssi_recipient_eligible— expectassigned_coa == "ssi_medicaid" -
hierarchy_picks_most_advantageous— applicant eligible for both parent_caretaker and PeachCare; expectassigned_coa == "parent_caretaker"
Verify Step 5:
-
cargo xtask rules checkshows 4 Medicaid files passing. -
cargo nextest run -p canopy-medicaidpasses. The lib test count decreases by ~30 (removed inline-Rust unit tests) while the integration test count increases by ~9 (new HTTP-path tests). -
grep -n "evaluate_magi_coa\|evaluate_chip_coa\|evaluate_non_magi_coa\|_rules:" services/canopy-medicaid/src/determine.rsreturns nothing. -
canopy-medicaid request logs show evaluate calls hitting canopy-rules over HTTP.
-
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 callsrules::check()from Step 1; confirm it bails on failure (this step makes the gate enforcing now that every file compiles) -
.gitlab-ci.yml— add arules-checkjob at the lint/check stage that runscargo xtask rules check. Fast (< 30s, no devstack required). -
Audit and remove any remaining
if resp.status == 500 { eprintln!(…); return; }patterns inservices/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:
-
cargo xtask validateruns end-to-end including rules check. -
CI pipeline has a rules-check stage that fails fast on any schema regression.
-
grep -rn "broken JDM ruleset\|JWT issuer mismatch\|upstream service issue" services/*/tests/returns zero results.
Step 8: End-to-end verification
-
cargo xtask dev refresh --shared-db— bring up devstack with all changes -
docker compose logs canopy-rules | grep "rule set loaded"— confirm count ≥ 12 with zerofailed to compilewarnings -
cargo xtask rules check— exit 0 -
cargo xtask test— full unit + integration battery passes -
cargo xtask validate— full pre-push gate passes -
cargo xtask policy audit— citation count unchanged from baseline (150/150 when this plan started) -
cargo nextest run -p canopy-snap -p canopy-tanf -p canopy-medicaid— every determine integration test passes with strong assertions, no skips -
Manual smoke: POST
/v1/determineagainst 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 |
|---|---|
|
NEW — |
|
Add |
|
Register |
|
Call |
|
Add |
|
Auto-importer scans |
|
Add |
|
Step 2 — replace bespoke client with thin wrapper around |
|
Step 2 — add |
|
Step 2 — wire new rules client constructor |
|
NEW — Step 2 smoke test (ignored until Step 4) |
|
Step 2 — replace bespoke client; Step 5 — extend input structs with threshold fields |
|
Step 2 — add |
|
Step 2 — wire new rules client constructor |
|
Step 5 — add bearer token extraction + |
|
Step 5 — wire |
|
Step 5 — 9 strong-assertion integration tests for MAGI/CHIP/non-MAGI/hierarchy paths |
|
NEW — Step 2 smoke test (ignored until Step 5) |
|
Step 3 — remove |
|
Step 3 — remove "broken JDM ruleset" skip; strengthen existing test; add 3 new strong-assertion tests |
|
NEW — alien eligibility integration tests |
|
Step 4 — remove "broken JDM ruleset" skip; strengthen existing test; add 4 new strong-assertion tests |
|
Full rewrite — preserve current intent in zen 0.55 schema |
|
Minimal valid stub in zen 0.55 schema |
|
Full rewrite (also fixes the "no dependent children returns eligible=true" bug in the current file) |
|
Full rewrite |
|
Full rewrite |
|
Full rewrite encoding |
|
Full rewrite encoding |
|
Full rewrite encoding |
|
Full rewrite encoding the EE15 precedence cascade |
|
Minimal valid stub |
|
Minimal valid stub |
|
Full rewrite encoding 7 CFR 273.4 categories; rename |
|
Add |
Verification
-
cargo nextest run --workspace --lib— unit tests pass (including the Medicaid lib-test count dropping by ~30 after Step 5 deletion) -
cargo xtask dev refresh --shared-db— bring up devstack with every rewrite -
cargo xtask rules check— every JDM ruleset compiles cleanly under zen-engine 0.55 -
cargo nextest run --workspace— every integration test passes, no skips -
cargo xtask validate— full pre-push gate passes (now includesrules check) -
cargo xtask policy audit— citation count remains 150/150 (no regression) -
docker compose logs canopy-rules | grep -c "rule set loaded"— ≥ 12 with zerofailed to compilewarnings
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 thecargo xtask rules checkgate -
CHANGELOG.adoc— entry under== Unreleased: "Fix: rewrite all JDM ruleset files against zen-engine 0.55 schema; addcargo xtask rules checkCI 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.adocthrough3618.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.adocthrough1395.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.adocthrough2900.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:
-
The existing
canopy-rules/src/engine.rsreinvented zen-engine’s loader caching pattern (customHashMap<String, Arc<Decision>>cache) and its!Sendfuture handling (bespokestd::thread+mpscchannel). Both have documented, more idiomatic replacements in the zen-engine 0.55 Rust SDK (FilesystemLoader+CachedLoaderfor loading,LocalPoolHandlefromtokio-utilfor!Sendfutures). -
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:
-
NamedFilesystemLoaderinservices/canopy-rules/src/engine.rs— customDecisionLoaderimpl that maps logical ruleset names (thenamefield inside each JDM file) to on-disk paths. Scansrulesets/federal/thenrulesets/{jurisdiction}/at startup, skips non-JDM parameter files by checking for a top-levelnodesarray. -
Wrapped in zen-engine’s
CachedLoaderfor memoization per the SDK doc’skeep_in_memory: trueequivalent. -
LocalPoolHandle::new(1)fromtokio-utilfor pinned!Sendevaluation futures. The pinned closure serializes the zen response to plain JSON before returning so onlySendtypes cross the thread boundary. -
?trace=truequery parameter onPOST /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 outerDisplay(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 newGET /v1/rule-sets/{name}(read by logical name) are retained. -
Drops the
rule_setsDB table via migration20260412000000_drop_rule_sets_table.sql. It was a pre-loader cache that was never authoritative after the auto-import landed. Therule_evaluationsaudit 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_compilesruntime gate which exercises all 12 on-disk JDM files via the real loader path. -
Eight tests that depended on
POST /v1/rule-setscreation 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:-
passThrough: trueon 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 theTransformAttributes::pass_throughflag onDecisionTableContentandExpressionNodeContent(defaultfalse). Every non-trivial graph in Steps 4-5 needs this on each transform node. -
Ternary
a ? b : cis the only conditional syntax. ZEN does not supportif/then/else. Every conditional expression must use?:. Nested ternaries work:cond1 ? val1 : (cond2 ? val2 : val3). -
max([a, b])/min([a, b])take a single array argument. The SDK doc’s brief reference tomax(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 inexpr-benefitinsidegeorgia-snap-eligibility. -
rulesets/federal/snap-alien-eligibility.json—namefield renamed fromfederal-snap-alien-eligibilitytogeorgia-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-benefitedge skippedexpr-deductions, leavingnet_incomeundefined whenexpr-benefitcomputedround(net_income * 0.30, 2). Fixed by routingexpr-standard-ce → expr-deductions → dt-net-income (bypass row) → expr-benefitso net_income is always computed. This was never caught before because no test had ever reachedexpr-benefitin the standard-CE arm. -
Decimal → JSON number conversion.
rust_decimal::Decimalserializes as a JSON string by default, which would make ZEN expressions like0.20 * gross_earned_incomefail on type mismatch.services/canopy-snap/src/determine.rsnow converts every Decimal inrules_inputto a bare JSON number viato_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=trueunblocked 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 oncargo xtask rules checkfor static schema validation ANDcurl -X POST /v1/evaluate?trace=trueagainst the live service for runtime trace. -
unwrap_ormasks removed.services/canopy-snap/src/alien_eligibility.rspreviously usedunwrap_or(false),unwrap_or("no reason provided"), andunwrap_or("7 CFR 273.4")to mask output schema mismatches. Replaced with hardok_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:
-
Call
alien_eligibility::build_inputfrom insidedetermine()for each non-citizen household member. -
Call
alien_eligibility::evaluatewith the assembled input. -
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.jsonhad an r-no-children rule with all-empty conditions that always matched as a fallback AND outputeligible: true— so a household with 0 dependent children would have been approved. The rewrite fixes this:c-children: "⇐ 0" → denywith"No dependent children"in the denial reasons. Documented inpost_determine_no_dependent_children_denies. -
TanfBenefitInputfield rename. The oldstate_max_benefit: Decimalandpayment_standard: Decimalfields were always passed asDecimal::ZERObydetermine.rswith 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 witheffective_date: NaiveDateandexpiration_date: NaiveDate, whichdetermine.rsnow computes (today → +6 months) and the rulesetpassThrough`s to its output unchanged. The ruleset’s new `family_maximumternary 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-strfeature) deserializes from strings and serializes as strings. But ZEN arithmetic needs numbers on input, and the JDM emits numbers on output. Fix: localserialize_decimal_as_numberhelper forTanfBenefitInput.countable_incomeand everyTanfEligibilityInputDecimal field (converts viato_f64); localdeserialize_decimal_from_numberforTanfBenefitOutput.benefit_amount(accepts JSON numbers OR strings). Same pattern will be needed in Step 5 forMagiInput/NonMagiInput/ChipInput/HierarchyInputon 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 intojurisdiction.tomlunder a new[tanf.family_maximum]section and have canopy-tanf inject the values intoTanfBenefitInputfrom 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-alignmentplan and should compose cleanly with this rewrite — add new expression nodes beforeexpr-benefitthat 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_exemptintegration test POSTs directly tocanopy-rules /v1/evaluateagainst thetanf-work-requirementsruleset, mirroring the Step 3 alien-eligibility test pattern. A follow-up should add aPOST /v1/work-requirements/evaluatehandler to canopy-tanf that wraps the rules client call — similar to how canopy-snap’spost_determinewraps its own rules evaluation. -
Step 2 smoke test deleted.
services/canopy-tanf/tests/rules_client_smoke_test.rswas added in Step 2 as a stopgap check that the consolidated rules client didn’t 400 on therule_set_nametypo. 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:
-
TanfParameterTableextended to load[tanf.earned_income].disregard_amount_cents(PAMMS 1615 flat $250 disregard per employed individual).#[allow(dead_code)]removed fromgross_income_ceilingandstandard_of_need— both are now used. -
determine.rscomputes real net income. Previouslylet 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, applymin(earner_gross, flat_disregard)per PAMMS 1615, subtract the total from gross income, clamp to zero. -
TanfEligibilityInputextended withgross_income_ceilingandstandard_of_needfields injected from the parameter table per ADR-011 — never hardcoded in the ruleset. -
tanf-eligibility.jsonnow tests income. Two new rules:r-gross-overcomparesgross_income > gross_income_ceiling→ denies with PAMMS 1501 gross ceiling reason;r-net-overcomparesnet_income > standard_of_need→ denies with PAMMS 1501 SON reason. Both rules set the corresponding*_passoutput field tofalseso downstream consumers can surface which gate failed. -
Decimal serde helpers moved to shared crate.
canopy-rules-client::decimal_serde(NEW) ownsserialize_as_number/deserialize_from_number/serialize_opt_as_numberwith 8 unit tests. canopy-tanf’s localserialize_decimal_as_number/deserialize_decimal_from_numberfunctions 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.rsusesmatches!(item.income_type.as_str(), "wages" | "self_employment" | "self_employment_net")to decide what counts as earned. Should migrate tocanopy_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-alignmentplan 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(plusload_thresholds_from_jurisdictionandMedicaidThresholds) removed fromdetermine.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 theTanfParameterTablepattern: loadsrulesets/federal/fpl-2026.json(HH-indexed monthly FPL) +rulesets/{jurisdiction}/jurisdiction.toml [medicaid](percentage thresholds) once at startup. Injected viaExtension<Arc<MedicaidParameterTable>>. Replaces the per-requestload_thresholds_from_jurisdictionthat did filesystem I/O with silent fallback defaults. -
Hierarchy ruleset uses parenthesized ternaries. The
medicaid-eligibility-hierarchy.jsonexpression node first computes 12some(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; addedhierarchy_jdm_evaluates_in_processunit test that evaluates the JDM in-process to catch this class of error in CI. -
Parallel rules evaluation via
tokio::try_join!.determine.rsfiresevaluate_magi,evaluate_non_magi, andevaluate_chipconcurrently 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 bymedicaid-implementation.adoc. -
Non-MAGI ABD COAs largely stubbed. SSI auto-qualify works; QMB/SLMB/QI-1/AMN return
falsewith stub denial reasons matching the deleted inline Rust. Full ABD expansion tracked bymedicaid-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 sharedArc<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 takestoken: Option<&str>per-call;set_token()deleted;tokiodep dropped from canopy-rules-client. -
Missing TANF income denial test coverage. The
r-gross-overandr-net-overrules added in Step 4 had zero integration test coverage. Addedpost_determine_gross_income_over_ceiling_deniesandpost_determine_net_income_over_son_denies. -
Misnamed Medicaid test.
post_determine_adult_denied_when_not_parent_caretakeractually asserted Pathways approval. Renamed topost_determine_adult_under_100_pct_fpl_gets_pathways. -
Unnecessary hierarchy HTTP call on full denial. When
eligible_codesis 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 xtaskin CI.cargo xtask rules checkalready triggers the build implicitly. Removed the extra line.
Verification: 810/810 workspace tests pass after fixes.