Plan: ADR-011 Hardcoded Policy Values Sweep
On this page
Status
Each numbered step below is one MR. Pick any step whose upstream dependencies are Done.
| Step | Description | Status |
|---|---|---|
Phase 1 — CI guards (raise the floor first) |
||
1.1 |
Extend |
Done (2026-04-20) — detector lands with |
1.2 |
Add grep guard as new |
Done (2026-04-20) — |
1.3 |
Ruleset-input lint: new |
Done (2026-04-20) — lint lands with |
1.4 |
Wire |
Done (2026-04-20) — see Errata for the surprise that the code-side work was already present. CI job lands with |
Phase 2 — Highest-impact consolidations |
||
2.1 |
|
Done (2026-04-20) — |
2.2 |
Silent |
Done (2026-04-20) — all 16 flagged violations across canopy-applications (2), canopy-caps (5), canopy-renewals (5), canopy-snap (1), canopy-wic (3) converted to |
Phase 3 — Ruleset input plumbing (parallelizable) |
||
3.1 |
TANF ruleset inputs. |
Done (2026-04-20) — 5 TANF ruleset-input violations resolved. |
3.2 |
Medicaid + CHIP ruleset inputs. |
Done (2026-04-20) — 21 Medicaid/CHIP ruleset-input violations resolved across 3 rulesets. Added 17 new age / LOS threshold keys to |
3.3 |
SNAP ruleset inputs. |
Done (2026-04-20) — 3 SNAP ruleset-input violations resolved. |
Phase 4 — Reporting externalization |
||
4.1 |
|
Done (2026-04-20) — deleted 4 Rust |
Phase 5 — Single-service residuals |
||
5.1 |
SNAP budgeting factors Rust-side. |
Done (2026-04-20) — 3 |
5.2 |
SNAP ABAWD time-limit constants. |
Done (2026-04-20) — 5 ABAWD time-constant literals in |
5.3 |
Pay-period conversions. |
Done (2026-04-20) — 8 hardcoded calendar literals eliminated (4 × |
5.4 |
Application-processing deadlines. |
Done (2026-04-20) — 6 hardcoded deadlines eliminated. New |
5.5 |
WIC food-package assignment → JDM. |
Done (2026-04-20) — food-package decision tree (category × age-breakpoint × breastfeeding → package key) externalized to a new |
5.6 |
Small-scope parameter externalizations (one MR for these grouped, or split if reviewers prefer): CAPS age gates (13 / 19) → |
Done (2026-04-21) — 7 hardcoded literals externalized in one MR: CAPS age gates (13 / 19) in |
5.7 |
Missing citations for values already in |
Done (2026-04-21) — 9 missing citations backfilled (the audit flagged 9, not 4 — the wic.food_packages / wic.certification_periods_months keys in the plan’s prose are federal-data keys that live in |
Branch pattern: feature/adr-011-sweep-{step-id} (e.g., feature/adr-011-sweep-1-1)
Labels: type::compliance, priority::high (Phase 1+2), priority::medium (Phase 3+4), priority::low (Phase 5), service::<affected>, workflow::ready
Context
ADR-011 (Policy-to-rules traceability) requires every policy value — thresholds, percentages, durations, dollar amounts — to trace to an authoritative source via citations.toml and live in jurisdiction.toml (parameters) or under rulesets/ (eligibility logic). Rust source should carry none of these values except as transient injection points.
On 2026-04-20, during canopy-web-persons-wiring MR work (!82), the author introduced four hardcoded jurisdiction percentages in UI display copy (90% TANF disregard, 50% / 85% CAPS SMI, 185% WIC FPL). That incident triggered a codebase-wide audit via 5 parallel contextless subagents, written up in hardcoded-policy-values-audit-2026-04-20. Result: approximately 90 hardcoded policy values across SNAP, TANF, Medicaid / CHIP, CAPS, WIC, and shared crates.
This plan sequences those findings into executable work so they can be closed in phases rather than ad-hoc.
Why a phased plan, not per-finding plans
-
The findings share infrastructure: same
params.rspattern across 7 services, same JDM-input plumbing across 3 programs, same CI-gate integration point. -
Sequencing matters: CI guards should land before the remediation sweeps so existing violations are fixed against a rising floor and new violations cannot sneak in.
-
A single umbrella plan is easier to prioritize, re-sequence, and hand to a contextless agent or human picking up any step.
Scope
In scope:
-
All findings enumerated in the 2026-04-20 audit.
-
CI enforcement to prevent regressions.
-
Missing
citations.tomlentries for values already injurisdiction.toml.
Out of scope:
-
FPL / SMI / need-standard table value updates — the values themselves are already data-driven via
rulesets/federal/fpl-2026.json/smi-2026.json. This plan moves multipliers (1.30, 1.85, 0.85) and thresholds that consume those tables. -
Operational constants (connection pool sizes, retry counts, timeout values, rate-limit windows) — performance-tuning knobs, not policy. CI guards include narrow allowlists.
-
HTTP status codes, port numbers, UUID versions, cryptographic parameters.
-
Pre-existing plan errata items tracked separately (e.g.,
SelfEmploymentNetdisregard applied to net not gross, WPR child-under-6 threshold implementation). Those remain in their owning plan. -
templates/applications/process.html"130% FPL" / "100% FPL" display strings — labels next to dynamically-fetched dollar values; cosmetic UX concern. Flagged in the audit for future consideration.
Dependencies
Between phases
-
Step 2.2 depends on Step 1.1 — the CI gate must exist before the sweep so new
unwrap_or(N)additions cannot regress. -
Step 3.3 and Step 5.1 should land in the same release window so
snap-budgeting-factors.jsonhas exactly one consumer pattern at a time. -
Step 5.7 depends on Step 1.4 — the reverse citation walker catches the missing citations it is meant to enforce.
-
All other steps are independent and can ship in any order.
External / cross-plan
-
None.
cargo xtask policy auditandcargo xtask rules checkalready exist as integration points.
Integration points (canonical files)
-
xtask/src/policy.rs— audit subcommand; extend for Step 1.1 + 1.4. -
xtask/src/rules.rs— rules-check; extend for Step 1.3. -
.gitlab-ci.yml— addadr-011-grep-guardin Step 1.2 alongside existingcompliance-data-tenancy. -
rulesets/federal/cross-program-2026.json— already exists with citations; Step 2.1 wires consumers. -
rulesets/federal/snap-budgeting-factors.json— already exists with citations; Steps 3.3 + 5.1 wire consumers. -
policy/georgia/jurisdiction.toml— all Phase 2 + 5 steps add keys. -
policy/georgia/citations.toml— every new key gets a citation. -
New files under
compliance/for the three allowlists (Steps 1.1, 1.2, 1.3). -
New files under
rulesets/federal/for Step 4.1 (tmsis-coverage-group-map.json,cms-416-2026.json) and Step 5.5 (wic-food-package-assignment.json).
Design
Phase 1 — CI guards
Step 1.1 — params.rs unwrap detector
Pattern to flag:
// VIOLATION: silent fallback to a federal value
config.snap.gross_income_limit_pct_fpl.unwrap_or(130)
// OK: fails loud (fix applied in Step 2.2)
config.snap.gross_income_limit_pct_fpl
.with_context(|| "snap.gross_income_limit_pct_fpl missing — required per ADR-011")?
Implementation: new audit-unwraps subcommand on cargo xtask policy audit. Walks every services//src/*/params.rs plus the known offenders outside params.rs (canopy-snap/src/verification.rs:23,272). For each .unwrap_or(<expr>) where <expr> is a numeric or decimal literal, fails unless the file:line matches compliance/adr-011-unwrap-allowlist.toml.
Allowlist format (each entry requires a reason):
[[unwrap_allowlist]]
file = "services/canopy-api/src/idempotency.rs"
line = 123
expression = "unwrap_or(3600)"
reason = "Cache TTL — operational knob, not policy. Safe default when env var unset."
Exit codes: nonzero if any violation outside the allowlist. Runs in the existing compliance-policy-audit CI job.
Step 1.2 — grep guard
Rationale: audit-unwraps only covers params.rs. The broader pattern (Decimal::from(<literal>) / dec!(<literal>) / bare numeric constants used in calculation) appears across every service. A ripgrep-based CI job gives full coverage without AST walker complexity.
Runs as new adr-011-grep-guard CI job:
rg --pcre2 -n '\b(Decimal::from|dec!)\s*\(\s*[0-9]+\s*\)' \
-g 'services/*/src/**' \
-g '!services/*/src/**/params.rs' \
-g '!**/tests/**' \
> violations.txt || true
# Compare against allowlist, fail on any line not in allowlist.
Allowlist: compliance/adr-011-grep-allowlist.toml. Entries require reason. Expected initial allowlist: Decimal::ZERO usages (literal but semantically a zero marker), numeric tier indices, test data.
Step 1.3 — ruleset-input lint
Pattern to flag:
// VIOLATION — 19 is CHIP max age, should be context.thresholds.chip_max_age
{"expression": "applicant_age >= 18 and applicant_age <= 21"}
// OK
{"expression": "applicant_age >= context.thresholds.chafee_min_age"}
Implementation: extend xtask/src/rules.rs check subcommand with a lint-inputs mode that loads every .jdm.json, walks the rule tree, and flags numeric literals appearing in expression / condition strings not adjacent to a context. / input. / $ prefix. False positives are inevitable (e.g., count >= 0 where 0 is a genuine comparison floor); per-ruleset allowlist in compliance/adr-011-ruleset-input-allowlist.toml.
Step 1.4 — CI wiring for the (already-existing) reverse citation walker
Superseded by actual implementation — see Errata. The plan originally called for extending cargo xtask policy audit with a reverse walker. On inspection, crates/canopy-policy/src/citation.rs::validate already walks jurisdiction.toml keys → citations and emits MissingCitation errors; the audit exits non-zero on 9 existing gaps (caps.copayment_tiers, caps.default_provider_rate_cents_per_hour, 7× tanf.wpr.*).
The real gap: cargo xtask policy audit has no CI job. It only runs manually. Step 1.4 adds the adr-011-policy-audit CI job (with allow_failure: true until Phase 5.7 backfills the 9 missing citations).
Follow-up consideration (Potential Improvements): integrate policy audit into cargo xtask validate so it runs pre-push alongside the rest of the battery. Not in scope for Step 1.4.
Phase 2 — Highest-impact consolidations
Step 2.1 — cross_program.rs consolidation
Delete from crates/canopy-reference/src/cross_program.rs:
pub const TMA_COVERAGE_MONTHS: u32 = 12;
pub const TMA_QRF_DUE_MONTHS: &[u32] = &[4, 7, 10];
pub const EXPRESS_LANE_MEDICAID_FPL_PCT: u32 = 235;
pub const EXPRESS_LANE_PEACHCARE_FPL_PCT: u32 = 247;
pub const EXPRESS_LANE_MAX_AGE: u32 = 19;
pub const TSNAP_CERTIFICATION_MONTHS: u32 = 5;
pub const TSNAP_TRIGGER_REASONS: &[&str] = &[...];
Replace with a CrossProgramParameterTable loaded from rulesets/federal/cross-program-2026.json at service startup. The JSON file already has full citations in rulesets/federal/citations.toml.
#[derive(Debug, Clone, Deserialize)]
pub struct CrossProgramParameterTable {
pub tma: TmaParams,
pub express_lane: ExpressLaneParams,
pub tsnap: TsnapParams,
}
impl CrossProgramParameterTable {
pub fn load(rulesets_dir: &Path) -> anyhow::Result<Self> {
let path = rulesets_dir.join("federal/cross-program-2026.json");
let raw = std::fs::read_to_string(&path)?;
Ok(serde_json::from_str(&raw)?)
}
}
Rewire callers:
-
services/canopy-medicaid/src/tma.rs:44,56,59— extendMedicaidParameterTableto also hold aCrossProgramParameterTablereference; consume from it. -
services/canopy-medicaid/src/express_lane.rs:54,70,72— same. -
services/canopy-snap/src/tsnap.rs(TSNAP subscriber) — consume viaSnapParameterTableextension.
Error handling: if cross-program-2026.json fails to load at startup, the service panics with a clear message. Missing policy file is a bootstrap error, not a runtime error.
Step 2.2 — silent-unwrap sweep
Affected files (from the audit):
| File | Hardcoded fallbacks to remove |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Pattern per file:
// BEFORE
gross_income_limit_pct_fpl: doc["snap"]["gross_income_limit_pct_fpl"]
.as_integer()
.map(|i| i as u32)
.unwrap_or(130), // ← silent federal default
// AFTER
gross_income_limit_pct_fpl: doc["snap"]["gross_income_limit_pct_fpl"]
.as_integer()
.map(|i| i as u32)
.with_context(|| "snap.gross_income_limit_pct_fpl missing from jurisdiction.toml — required per ADR-011")?,
For each removed fallback: add the key to jurisdiction.toml + citation to citations.toml. Some keys already exist in the TOML but were being silently overwritten by the fallback — for those, verify the value and add the citation.
Verification: after the sweep, temporarily delete one key from jurisdiction.toml and confirm the affected service fails to start with a clear error message. Restore the key. Include one such smoke-test transcript in the MR description.
Phase 3 — Ruleset input plumbing
Each sub-step replaces JDM literals with context.thresholds.* references and extends the service’s ParameterTable to pass the values as rules_input at evaluation time. Mechanic is identical across the three steps; ruleset-specific literals are enumerated below.
| Ruleset | Literals to lift | Target threshold names |
|---|---|---|
|
Ages 18 / 59 (adult gate, lines 47, 55, 62, 70); 12-month infant exemption (lines 94, 100); 72-month under-6 cutoff (line 154); 20 / 30 / 35 hours (lines 146-147, 161-162, 176-177) |
|
|
|
|
|
Ages 1 / 6 / 19 (C19 / PC bands, line 37); 26 (FFCM, line 55); 1 (newborn, line 61); 18-64 (WHM, line 73); 18-44 (P4HB, line 79) |
|
|
Age 65 (ABD, lines 37, 43, 49, 127); age 19 (TEFRA, line 109); 30-day hospital LOS (line 121); 18-21 (Chafee, line 169) |
|
|
|
|
|
|
|
All keys except SNAP’s already exist in jurisdiction.toml under [tanf.wpr], [tanf.time_limits], or [medicaid]. SNAP’s come from rulesets/federal/snap-budgeting-factors.json (extends SnapParameterTable — see Step 5.1).
Rust side: each program’s determine.rs already constructs rules_input via its ParameterTable. Extend that construction to include the new named thresholds under the thresholds key. Also update the Rust-side duplicates: canopy-medicaid/src/determine.rs:423/446/484, au_composition.rs:97/100, and main.rs:258 to consume the new parameters instead of their own hardcoded literals.
Phase 4 — Reporting externalization
Step 4.1 — T-MSIS + CMS-416 tables
New file rulesets/federal/tmsis-coverage-group-map.json:
{
"_description": "COA → T-MSIS eligibility-group + disability + dual-eligible flags per CMS T-MSIS Data Dictionary (section 2.4.3). Loaded by canopy-reporting at startup.",
"_citation": "CMS T-MSIS Data Dictionary v2.3 (2024), ELG-ELIGIBILITY-GROUP values",
"coverage_groups": {
"EE15": "FAMLY",
"CHIP": "CHIP",
"QMB": "DL-QMB",
"SLMB": "DL-SLMB",
"...": "..."
},
"disability_indicator_coas": ["SSI", "DW", "ABD", "ICWP", "NH", "QDWI", "TEFRA", "..."],
"dual_eligible_coas": ["QMB", "SLMB", "QI_1"]
}
Each key needs a citation in rulesets/federal/citations.toml under [citations."tmsis.<field>"].
New file rulesets/federal/cms-416-2026.json:
{
"_description": "CMS-416 EPSDT reporting age bands per 42 CFR 441.56. Federal reporting spec, updated annually.",
"_citation": "CMS-416 instructions, §1905(r)",
"age_bands": [
{"min": 0, "max": 1, "label": "Under 1"},
{"min": 1, "max": 2, "label": "1-2"},
{"min": 3, "max": 5, "label": "3-5"},
{"min": 6, "max": 9, "label": "6-9"},
{"min": 10, "max": 14, "label": "10-14"},
{"min": 15, "max": 18, "label": "15-18"},
{"min": 19, "max": 20, "label": "19-20"}
],
"epsdt_max_age": 21
}
canopy-reporting/reporting/medicaid.rs changes:
-
Lines 19-39 (disability allowlist) →
tmsis_map.disability_indicator_coas.contains(coa) -
Lines 42-44 (dual-eligible allowlist) →
tmsis_map.dual_eligible_coas.contains(coa) -
Lines 48-96 (coverage-group map) →
tmsis_map.coverage_groups.get(coa_code) -
Lines 271-277 (federal fiscal-quarter mapping) → new
rulesets/federal/fiscal-calendar.jsonOR keep inline with 31 USC §1102 citation (operational-adjacent; not jurisdiction-specific). -
Lines 347-355 +
:393(CMS-416 age bands + EPSDT max age 21) →cms416.age_bands/cms416.epsdt_max_age.
Load both files at service startup via a new ReportingParameterTable.
Phase 5 — Residuals
Each step is a single focused MR. Pattern is identical across Steps 5.1-5.6: move value(s) from Rust to jurisdiction.toml (or a new federal ruleset file for 5.5), add citation, update consumer, verify. Step 5.7 is citations-only — no Rust changes.
Status-table rows have exact file:line + target keys. Full context per finding is in the audit doc (hardcoded-policy-values-audit-2026-04-20).
Steps
All implementation detail lives in the Design section above. Each Status-table row is a one-line summary; the implementer cross-references the audit doc for the full finding context and cites the matching audit line in the MR description.
Files Touched
Per-step detail is in Design. Categorically:
| Category | Files |
|---|---|
CI + xtask (Phase 1) |
|
Shared-crate constant removal (Phase 2) |
|
Rulesets (Phase 3) |
|
New federal rulesets (Phases 4, 5.5) |
|
Reporting (Phase 4) |
|
Consumer Rust code (Phases 3 + 5) |
|
Policy data |
|
Documentation |
|
Verification
Per-MR verification
Each step includes:
-
cargo nextest run -p <affected service>— existing + any new tests pass. -
cargo fmt --all --check+cargo clippy -p <service> --all-targets — -D warnings. -
cargo xtask policy audit— green (Phase 1 items strengthen this; every later step must continue to satisfy it). -
cargo xtask rules check— green (includeslint-inputsafter Step 1.3). -
cargo xtask validate— full battery green. -
For Phase 2 + 5: temporarily delete a key from
jurisdiction.toml; confirm the affected service fails to start with a clear error naming the missing key; restore. Include transcript in MR description. -
For Phase 3: run the affected JDM happy-path test (
cargo nextest run -p canopy-rules-client --test ruleset_happy_path_test) to confirm the ruleset still evaluates with the newcontext.thresholds.*inputs. -
For Phase 4: regenerate one T-MSIS / CMS-416 report against seed data; diff byte-for-byte against a pre-change baseline; differences should be zero (values come from the same source, just loaded differently).
Plan-level verification (after all steps complete)
-
cargo xtask policy audit— zero violations across the repo. -
rg --pcre2 '\.unwrap_or\(\s*[0-9]+\s*\)' services//src/*/params.rs— zero matches (except allowlisted). -
rg --pcre2 '\b(Decimal::from|dec!)\s*\(\s*[0-9]+\s*\)' services//src/*/*.rs— only allowlisted matches. -
Run one fresh end-to-end determination per program against seed data; compare to pre-sweep baseline; byte-identical or justified difference.
-
Close all audit findings in hardcoded-policy-values-audit-2026-04-20 by adding a "Remediated" section linking each finding to its MR.
Documentation Updates
Per step:
-
Update this plan’s Status row to "Done ({date}) — {MR URL}"
-
CHANGELOG.adoc—== Unreleasedentry under=== Changed(or=== Addedfor new ruleset files) -
docs/modules/ROOT/pages/roadmap.adoc— update relevant row if Tier 5.5 / Tier 7 row covers the specific finding
At plan completion:
-
Update the audit doc’s top matter to reflect remediation status + add a "Remediated" section with MR-per-finding
-
Add an "ADR-011 Compliance" section to
docs/modules/ROOT/pages/ato-readiness.adocdescribing the enforced CI gates as evidence -
Review / amend ADR-011 itself if the sweep reveals any ADR gaps (e.g., need for operational-constant carve-outs)
Errata
2026-04-20 — Step 1.4’s reverse citation walker already existed
The plan’s Step 1.4 Design described adding a "reverse citation walker" to cargo xtask policy audit — walking jurisdiction.toml keys and failing when no matching citations.toml entry exists. That was based on the plan author’s reading that the audit "only walks citations → values". That reading was wrong.
Inspection of crates/canopy-policy/src/citation.rs during Step 1.4 implementation revealed the validate function already does both walks:
-
Loop at lines 182-186 walks
flat_keys(from jurisdiction.toml) and emitsMissingCitationfor any key without a citation. -
Loop at lines 189-226 walks
manifest.citationsand checks consistency / staleness / schema.
MissingCitation is classified as an error (not a warning), and run_audit exits non-zero on any error (std::process::exit(1) at line 136). Running cargo xtask policy audit against main on 2026-04-20 emits 9 missing-citation errors and exits 1 — the reverse walker is working as intended.
The actual gap Step 1.4 closes: there was no CI job for the audit. It only ran manually or as part of pre-push cargo xtask validate (if even there — xtask/src/cmd/validate.rs does not invoke it). Step 1.4 lands the CI wiring as a new adr-011-policy-audit job with allow_failure: true until Phase 5.7 backfills the 9 missing citations.
Plan Design updated to reflect reality. Future Step 1.x authors: verify the integration point before assuming.
2026-04-20 — Step 5.5 reinterpreted "per ADR-003" as "per ADR-011"
The plan’s Step 5.5 description said "WIC food-package assignment → JDM. canopy-wic/src/params.rs:157-178 decision tree → new rulesets/federal/wic-food-package-assignment.json per ADR-003."
ADR-003 (ruleset-as-data) is scoped to eligibility logic — the determination call that decides whether an applicant qualifies for a program. WIC food-package assignment runs after eligibility has already been decided; it’s a configuration call that maps (category, age, breastfeeding) to a food-package letter (I / II / III / IV / V / VI / VII). The federal data already lived in rulesets/federal/wic-food-packages-2026.json; only the decision tree (key-selection logic) was hardcoded in Rust.
Implementation externalized the decision tree to a new assignment_rules section in the existing wic-food-packages-2026.json (single-file home for all WIC food-package data) and replaced the Rust match with a table walk. This satisfies ADR-011 — every policy value traces to a cited federal source; no jurisdictional variation — without inventing a new JDM ruleset for a non-eligibility post-determination call. The pattern matches Phase 4.1’s T-MSIS / CMS-416 externalization (flat federal JSON, not JDM).
Plan language updated in the Status row. Future plan authors: when a plan says "→ JDM", verify that the target is genuinely eligibility logic; configuration / mapping tables are better as flat federal data with _citation headers.
Potential Improvements
Out of scope for this plan but worth capturing:
-
Consolidated
[shared.timing]section injurisdiction.tomlfor the renewal / expungement / dashboard-lookahead windows that currently duplicate across 4+ files. The audit identified this as pattern #4 but Phase 5 only externalizes the individual values. A follow-up consolidation plan could fold them under a single section. -
Ruleset-parameter sync test — per-program integration test that asserts every
context.thresholds.<name>referenced by a ruleset has a corresponding entry in the service’sParameterTable. Stronger than thelint-inputspattern (Step 1.3) because it catches ruleset → parameter drift at compile time. -
ADR-011 evidence for ATO — the CI gates landed by Phase 1 are direct compliance evidence for the ATO package. Formalize the coverage statement: every policy value is either (a) in
jurisdiction.tomlwith acitations.tomlentry, (b) inrulesets/federal/*.jsonwith a_citationfield, or (c) in an allowlist with a written reason. -
templates/applications/process.htmlFPL display strings — the display "130% FPL" / "100% FPL" labels alongside dynamically-fetched dollar amounts are flagged in the audit for future consideration. Not urgent; cosmetic UX concern, not a policy-trace violation.
Tracked follow-ups (filed 2026-04-24 after audit of plan Errata + Potential Improvements sections across the repo):
-
#331 — Ruleset-parameter sync test (from Potential Improvements)
Tracked follow-ups (filed 2026-05-04 during PI sweep):