Plan: JDM Ruleset End-to-End Happy-Path Tests
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Add shared |
Done (2026-04-18) — |
2 |
Author canonical happy-path fixtures per program under |
Done (2026-04-27) — 12/12 fixtures fully populated. The two previously-ignored fixtures ( |
3 |
Write one integration test per active ruleset |
Done (2026-04-18) — 12 tests in |
4 |
Wire the new tests into |
Done (2026-04-18) — nextest auto-discovers; 10/10 non-ignored tests green in the pre-push hook |
5 |
Add a |
Done (2026-04-18) — new "Fixture-driven drift gate" section runs |
Branch: test/jdm-happy-path
Labels: type::test, priority::high, program::cross-program, service::rules, workflow::ready
Context
The 2026-04-18 repo review found that canopy-rules has "smoke tests only" — the current coverage verifies that the zen-engine wrapper starts and that its health/metrics endpoints respond. It does not verify that any production JDM ruleset actually evaluates correctly given a realistic input.
Per ADR-003, every eligibility decision traverses a JDM ruleset via canopy-rules. A broken ruleset — syntax error, renamed variable, removed expression node — surfaces only when the first downstream determination calls through. In practice this means ruleset regressions are detected at UAT time, far too late.
Active rulesets today (12 total):
| Ruleset | Program |
|---|---|
|
SNAP |
|
SNAP |
|
SNAP |
|
TANF |
|
TANF |
|
TANF |
|
Medicaid (MAGI) |
|
Medicaid (ABD/LTC/MN) |
|
CHIP |
|
Medicaid (EE15) |
|
CAPS |
|
WIC |
Each ruleset gets one canonical happy-path fixture and one test. The goal is not exhaustive coverage (that belongs to the per-program integration tests); it is a drift gate.
Scope
In scope:
-
One happy-path fixture + test per active ruleset, 12 total.
-
Shared
canopy_test_lib::ruleshelper that invokescanopy-rulesover HTTP. -
A pre-commit
cargo xtask rules checkgate validating all rulesets compile and their fixtures evaluate without panics.
Out of scope:
-
Denial-path and edge-case fixtures (belong to per-program plans, e.g.
snap-categorical-eligibility.adoc). -
Snapshot testing of output payload shapes (too brittle for data-driven rulesets that evolve with federal parameters).
-
Performance / load testing.
Dependencies
-
services/canopy-rules— already exposesPOST /v1/evaluate. -
crates/canopy-test-lib— already providesTestClientandinfrastructure_available. -
cargo xtask rules check— already exists perroadmap.adocTier 2 ("all 12 rulesets compile under zen-engine 0.55"); this plan extends it to run fixtures.
Design
Fixture layout
crates/canopy-test-lib/fixtures/rulesets/
├── snap-eligibility.json # input + expected_output_shape
├── snap-benefit-calculation.json
├── snap-categorical-eligibility.json
├── tanf-eligibility.json
├── …
└── wic-eligibility.json
Each fixture file:
{
"ruleset": "snap-eligibility",
"description": "Single-adult household at 100% FPL — standard approve",
"input": { … full ApplicationContext … },
"expect": {
"output_fields": ["status", "benefit_amount", "reasons"],
"status": "approved"
}
}
expect.output_fields asserts presence, not value, for fields that vary with federal parameter updates (benefit amount, FPL percentages). expect.status is the strong check.
Test helper
// crates/canopy-test-lib/src/rules.rs (new)
pub async fn evaluate_fixture(fixture_path: &str) -> Result<serde_json::Value, RulesError> {
let fixture: RulesetFixture = serde_json::from_str(&std::fs::read_to_string(fixture_path)?)?;
let cfg = TestConfig::from_env();
let client = TestClient::new(&cfg.rules_url);
let resp = client.post_json(
&format!("/v1/evaluate/{}", fixture.ruleset),
&fixture.input,
).await;
resp.assert_status(200);
let out = resp.json_value();
for field in &fixture.expect.output_fields {
assert!(
out.get(field).is_some(),
"ruleset {} output missing expected field `{}`",
fixture.ruleset, field,
);
}
if let Some(expected_status) = &fixture.expect.status {
assert_eq!(
out.get("status").and_then(|v| v.as_str()),
Some(expected_status.as_str()),
"ruleset {} status mismatch", fixture.ruleset,
);
}
Ok(out)
}
Test module layout
One integration test file:
// crates/canopy-rules-client/tests/ruleset_happy_path_test.rs (or services/canopy-rules/tests/...)
#[tokio::test]
async fn snap_eligibility_happy_path() {
if !canopy_test_lib::infrastructure_available().await { return; }
canopy_test_lib::rules::evaluate_fixture(
"../../crates/canopy-test-lib/fixtures/rulesets/snap-eligibility.json"
).await.expect("SNAP eligibility happy path");
}
// … one per ruleset
The shared helper keeps each test body short. Adding a new ruleset means: drop a fixture, add a 4-line test, done.
cargo xtask rules check extension
Current behavior: loads every JSON ruleset under rulesets/ and confirms it parses. Extend it to:
-
For every ruleset whose fixture exists under
crates/canopy-test-lib/fixtures/rulesets/, invoke the zen-engine evaluator directly (no HTTP) with the fixture’sinput. -
Fail the check if any fixture evaluation panics or the
expect.statusassertion fails.
The in-process invocation keeps the check fast (no devstack required) and makes it a legitimate pre-push gate. The HTTP tests in Step 3 remain the canonical end-to-end coverage.
Steps
Step 1: Shared helper
Files: crates/canopy-test-lib/src/rules.rs (new), crates/canopy-test-lib/src/lib.rs.
Implement RulesetFixture + evaluate_fixture per Design. Add pub mod rules; to lib.rs.
Step 2: Fixtures
Files: crates/canopy-test-lib/fixtures/rulesets/*.json — 12 files.
Each fixture targets a single "canonical approve" case per program. Use realistic but minimal inputs. Keep PII scrubbed — the fixture is checked into version control.
Step 3: Tests
Files: crates/canopy-rules-client/tests/ruleset_happy_path_test.rs (new).
One #[tokio::test] per ruleset. Guard each with infrastructure_available().
Files Touched
| File | Change |
|---|---|
|
New helper module |
|
|
|
12 new fixtures |
|
12 new tests |
|
Extend |
|
Entry under |
Verification
-
cargo xtask rules check— 12 fixtures evaluated in-process, all green -
cargo xtask test --integration— 12 new integration tests pass -
Deliberately break one ruleset (e.g., remove an expression node in a local copy), re-run
cargo xtask rules check— the corresponding fixture fails, check exits non-zero -
cargo xtask validate— full pre-push battery green
Documentation Updates
-
.claude/docs/testing.md— ruleset fixture convention noted in the validate step description (2026-04-18) -
CLI Reference — document extended
cargo xtask rules check(deferred; note added to fixture README) -
CHANGELOG.adoc— entry under== Unreleased(2026-04-18)
Errata
(Empty — the previously-recorded gap on the 2 ignored fixtures was closed in Step 2 on 2026-04-27.)
Potential Improvements
-
Expand
expect.output_fields/expect.exactusage. Current fixtures leaveexpectempty; the drift gate catches the ruleset failing to evaluate, but does not catch an edit that changes the output shape. Authoring explicit output-field presence checks for each ruleset would tighten the gate at the cost of more per-fixture maintenance. Worth doing once per-program integration tests stabilise. -
Auto-generate the input schema. The ruleset JSONs encode the expected input fields implicitly in expression bodies. A small parser over
nodes[].content.expressions[].valuecould emit a JSON-schema or TypeScript type per ruleset; fixtures would then be validated against the schema at check time and a promotion step could produce a skeleton for new fixtures.
Tracked follow-ups (filed 2026-04-24 after audit of plan Errata + Potential Improvements sections across the repo):
-
#330 — Auto-generate JSON-schema from JDM inputs (from Potential Improvements)