Rulesets: JDM Format, Jurisdiction Config, and Authoring

On this page

All eligibility logic lives in versioned JDM rulesets and jurisdiction.toml, never in Rust (ADR-003). This page is the authoring reference; the canonical traceability rules are in ADR-011.

Directory structure

rulesets/
├── federal/                          # Federal parameters (updated annually by HHS/FNS/CMS)
│   ├── fpl-2026.json                 # Federal Poverty Level (standard, Alaska, Hawaii)
│   ├── smi-2026.json                 # State Median Income
│   ├── snap-allotments-2026.json     # Max benefit allotment by household size (effective-dated: #1467)
│   ├── snap-deductions-2026.json     # Standard deductions, shelter cap, asset limits (effective-dated)
│   ├── snap-income-limits-2026.json  # 130% and 100% FPL monthly limits by household size (effective-dated)
│   ├── snap-budgeting-factors.json   # Static budgeting percents + pay periods (digest-covered: #1467)
│   └── snap-alien-eligibility.json   # 7 CFR 273.4 alien eligibility decision table
│
└── georgia/                          # Jurisdiction-specific (one directory per state; `default/` is the reference)
    ├── jurisdiction.toml             # All configurable thresholds and policy options
    ├── snap-eligibility.json         # SNAP eligibility JDM ruleset
    ├── snap-benefit-calculation.json # SNAP benefit calculation JDM ruleset
    ├── tanf-eligibility.json         # TANF eligibility JDM ruleset
    ├── medicaid-magi.json            # Medicaid MAGI pathway
    └── notices/                      # Typst notice templates
        ├── manifest.toml             # Maps template keys → versioned .typ files
        ├── components/               # Shared Orchard design system components
        └── snap/                     # SNAP notice/form templates

JDM format

JDM (JSON Decision Model) is the format the zen-engine rules library evaluates. Each ruleset is a JSON file with nodes and edges.

Node types

  • inputNode — entry point, receives input JSON.

  • outputNode — exit point, emits output JSON.

  • decisionTableNode — rows of input conditions → output values (most common).

  • expressionNode — key/value expressions for computed fields.

  • switchNode — conditional branching.

Hit policies

  • "first" — returns the first matching rule (most common; order matters).

  • "collect" — returns all matching rules (aggregation).

Decision table structure

{
  "id": "dt-example",
  "type": "decisionTableNode",
  "content": {
    "hitPolicy": "first",
    "inputs":  [{"id": "in-1",  "name": "Household Size", "field": "household_size"}],
    "outputs": [{"id": "out-1", "name": "Limit", "field": "gross_income_limit", "type": "number"}],
    "rules": [
      {"in-1": "<= 3", "out-1": "1696"},
      {"in-1": "4",    "out-1": "3481"},
      {"in-1": "",     "out-1": "0"}
    ]
  }
}

An empty string in an input cell is the default/catch-all (always matches).

NOTE

zen-engine 0.55 has several non-obvious behaviors (DT string-match unreliability, no string literals in ternaries, DT outputs always stringly-typed, passThrough: true required on transform nodes). These are documented in Known Issues under "JDM Rulesets" — read it before authoring.

jurisdiction.toml

All jurisdiction-specific thresholds live here; services load at startup via CANOPY_{SERVICE}__JURISDICTION. Abbreviated shape:

[jurisdiction]
name = "State of Georgia"
fips_state_code = "13"
timezone = "America/New_York"

# #1158: OBSERVED working-day holidays per covered year — consumed by the
# shared canopy-common workday calendar (Chart B2 "5 working days",
# Chart 3730.1 month-end closure + reopen SOP). Weekend-date entries are
# refused at boot (the list is observed dates by definition); beyond
# coverage_years the arithmetic degrades to the weekend-only floor
# (household-favorable at every consuming site).
[jurisdiction.holidays]
coverage_years = [2026]              # every covered year must list holidays
dates = ["2026-01-01", "2026-01-19"] # … the full observed list, cited

[snap]
bbce_enabled = true
bbce_gross_income_limit_pct_fpl = 130
bbce_asset_test_eliminated = true
initial_certification_period_months = 12
elderly_disabled_certification_period_months = 24

[snap.abawd]
qualifying_hours_per_month = 80        # Required — no silent defaults
time_limit_months = 3
window_months = 36

[snap.expedited]
low_income_limit_cents = 15000
liquid_assets_limit_cents = 10000

[snap.ipv]
first_offense_months = 12
second_offense_months = 24
third_offense_permanent = true
trafficking_permanent = true

[notices]
hearing_phone = "1-877-423-4746"
appeal_deadline_days = 90
advance_notice_days = 14

[appeals]
appeal_window_days = 90
decision_clock_days = 90
adh_notice_advance_days = 30

[tanf]
# ... full TANF/Medicaid/CAPS/WIC sections live in the file itself

Every value here must trace to an authoritative source via citations.toml (ADR-011); cargo xtask policy audit enforces completeness in CI. The federal source family is audited too (ADR-031 §1): every rulesets/federal/*.json data file must carry a file-level citation in rulesets/federal/citations.toml, and key-level citations are consistency-checked against the JSON values (--source all|jurisdiction|federal selects a family). See Configuration Reference for the full layered-config model.

Adding a ruleset

  1. Create rulesets/{jurisdiction}/{program}-{name}.json.

  2. Set the JSON "name" field to "{jurisdiction}-{program}-{name}" (e.g. "georgia-snap-eligibility") — the NamedFilesystemLoader loads by this top-level name, not the filename. A mismatch produces 404 rule set not found.

  3. canopy-rules scans rulesets/federal/ and rulesets/{jurisdiction}/ at startup; rulesets are versioned in git and reloaded only on service restart (no runtime mutation path).

  4. Test: POST /v1/evaluate to canopy-rules with {"rule_set_name": "georgia-snap-eligibility", …​} (add ?trace=true for a node-by-node execution trace).

ADR-003 compliance

All eligibility logic must live in JDM rulesets or jurisdiction.toml, not Rust.

  • Federal regulation values (FPL, thresholds, penalty periods, qualifying hours) → jurisdiction.toml or rulesets/federal/*.json.

  • Eligibility decision logic (income tests, categorical eligibility, alien eligibility) → JDM rulesets.

  • Rust only assembles input, calls the rules engine, and parses output.

  • Policy changes are data changes — no code deployment for threshold updates.

  • Missing required jurisdiction.toml keys fail at startup with .context("missing key: …​"), never silently default (the cargo xtask policy audit-unwraps gate enforces this).

Edit this page · default