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 cargo xtask policy audit to fail on */params.rs unwrap_or(<numeric literal>) patterns. Allowlist at compliance/adr-011-unwrap-allowlist.toml with required reason per entry.

Done (2026-04-20) — detector lands with allow_failure: true in CI; Step 2.2 will flip to blocking once the 16 existing violations are fixed. 3 defensive-fallback false positives in non-loader methods are allowlisted with reasons.

1.2

Add grep guard as new .gitlab-ci.yml adr-011-literal-audit job: flags new Decimal::from(<numeric>) / dec!(<numeric>) in non-test service src outside allowlist.

Done (2026-04-20) — cargo xtask policy audit-literals lands with allow_failure: true in CI; inline [cfg(test)] / [test] blocks are skipped; 15 calendar/percent constants (100 / 12 / 7 / 3) pre-allowlisted; 11 genuine violations remain for Phase 2 / 5 to resolve.

1.3

Ruleset-input lint: new cargo xtask rules lint-inputs parses every .jdm.json and flags numeric literals in expression-carrying strings (decision-table cells, expression-node values, switch-node conditions), skipping metadata fields (_comment, _description, _id, name, position/*, etc.), quoted display substrings, and precision-argument positions (round(x, 2)). New .gitlab-ci.yml job adr-011-ruleset-input-audit.

Done (2026-04-20) — lint lands with allow_failure: true; 33 genuine Phase 3 targets remain (TANF ages 18/59/12/72/48, Medicaid ages 19/26/45/65 + 30-day LOS, CHIP age 19, SNAP budgeting factors 0.20/0.30/0.50, CAPS < 5). 14 new unit tests covering expression detection, metadata pointer filter, literal extraction, quoted-string skip, allowlist matcher.

1.4

Wire cargo xtask policy audit into CI as adr-011-policy-audit (the reverse citation walker already exists in canopy-policy::citation::validate and flags 9 missing citations today; the gap was CI enforcement).

Done (2026-04-20) — see Errata for the surprise that the code-side work was already present. CI job lands with allow_failure: true while Phase 5.7 backfills the 9 missing citations.

Phase 2 — Highest-impact consolidations

2.1

cross_program.rs constant-table consolidation. Delete pub const`s in `crates/canopy-reference/src/cross_program.rs; load rulesets/federal/cross-program-2026.json via new CrossProgramParameterTable at startup; rewire tma.rs, express_lane.rs, TSNAP subscriber.

Done (2026-04-20) — CrossProgramParameterTable::load() replaces 8 pub const`s; event-routing string consts retained; canopy-medicaid + canopy-snap load once at startup and inject via axum `Extension + subscriber capture. Added tma.trigger_reasons field to cross-program-2026.json + two new citations (tma.trigger_reasons, tma.phase_1_months). TMA Phase-2 Decimal::from(205) removed from determine.rs. 984/984 workspace tests pass.

2.2

Silent unwrap_or(<federal>) sweep across 7 params.rs files (detail in Design). Replace every literal fallback with .with_context(|| "<key> missing from jurisdiction.toml")?; add missing keys + citations. Lands after 1.1 so CI enforces.

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 .with_context(…​)?. All TOML keys + citations pre-existed; no jurisdiction.toml additions needed. 4 allowlist line-number pointers updated for shift. adr-011-unwrap-audit CI job flipped from allow_failure: true to blocking. 214 affected-service tests pass.

Phase 3 — Ruleset input plumbing (parallelizable)

3.1

TANF ruleset inputs. rulesets/georgia/tanf-work-requirements.json + tanf-eligibility.json age / hour / duration literals → context.thresholds.* inputs. Plumb via TanfParameterTable.

Done (2026-04-20) — 5 TANF ruleset-input violations resolved. TanfParameterTable extended with work_requirement_age_min/max, infant_exemption_months_max, young_child_months_max, state_time_limit_months accessors. TanfEligibilityInput + WorkRequirementsInput grew the threshold fields with #[serde(default)] so API callers don’t break. evaluate_work_requirements handler overwrites them from params before forwarding. JDM cells like "< 18" became "< work_requirement_age_min". Added 2 new citations (tanf.wpr.infant_exemption_months_max, tanf.wpr.young_child_months_max). 73 TANF tests + 10/10 JDM happy-path fixtures pass. Lint violation count 33 → 28.

3.2

Medicaid + CHIP ruleset inputs. medicaid-magi.json / medicaid-non-magi.json / chip-eligibility.json age / LOS literals → context.thresholds.* inputs via MedicaidParameterTable.

Done (2026-04-20) — 21 Medicaid/CHIP ruleset-input violations resolved across 3 rulesets. Added 17 new age / LOS threshold keys to [medicaid] in jurisdiction.toml (e.g. child_age_newborn_max=1, child_age_pc_max=6, child_age_c19_max=19, chip_max_age=19, former_foster_care_max_age=26, parent_caretaker_min_age=19, pathways_min_age=19, pathways_max_age=65, p4hb_min_age=18, p4hb_max_age=45, whm_min_age=18, whm_max_age=65, abd_min_age=65, tefra_max_age=19, hospital_los_days_threshold=30, chafee_min_age=18, chafee_max_age=21) with 17 matching PAMMS/CFR citations. MedicaidParameterTable grew 17 accessors. MagiInput / NonMagiInput / ChipInput grew threshold fields with #[serde(default)]. determine.rs populates all fields from params before forwarding. JDM cells like "applicant_age < 1" became "applicant_age < child_age_newborn_max". 71/71 canopy-medicaid tests + 10/10 JDM happy-path fixtures pass. Lint violation count 28 → 5 (remaining are Phase 3.3 SNAP budgeting factors + 2 allowable SNAP alien 5-year bars).

3.3

SNAP ruleset inputs. rulesets/georgia/snap-eligibility.json:157/165/228 — 20% / 30% / 50% factors → context.thresholds.* from rulesets/federal/snap-budgeting-factors.json (extends SnapParameterTable). Pairs with 5.1.

Done (2026-04-20) — 3 SNAP ruleset-input violations resolved. rulesets/federal/snap-budgeting-factors.json grew earned_income_deduction.percent = 20 (7 CFR 273.9(d)(2), PAMMS 3611) and shelter_deduction.household_half_net_percent = 50 (7 CFR 273.9(d)(6)(ii), PAMMS 3617); the pre-existing benefit_computation.net_income_percent = 30 was also wired through. SnapParameterTable grew earned_income_deduction_pct / shelter_half_pct / au_net_income_pct (all Decimal::new(<pct>, 2)) loaded with .context(…​)? on missing keys. SnapParameters + for_household propagate; determine.rs injects as earned_income_deduction_pct / shelter_half_pct / au_net_income_pct into rules_input. JDM cells round(0.20 * gross_earned_income, 2) / round($.adjusted_after_medical * 0.50, 2) / round(net_income * 0.30, 2) became round(earned_income_deduction_pct * gross_earned_income, 2) / round($.adjusted_after_medical * shelter_half_pct, 2) / round(net_income * au_net_income_pct, 2). Added 4 per-key citations in rulesets/federal/citations.toml (the 3 percents + the PRWORA 5-year qualified-alien bar, which is federal statute and therefore allowlisted in compliance/adr-011-ruleset-input-allowlist.toml rather than plumbed). 134/134 canopy-snap tests pass (incl. new load_federal_budgeting_factor_percents); cargo xtask rules check 12/12 compile + 10/10 fixtures pass; cargo xtask rules lint-inputs violations 5 → 0 (clean) with 2 allowlist entries; cargo xtask policy audit-literals stays at baseline 10 violations (same Phase 5 targets). Ninth of 17 steps; completes Phase 3 (ruleset input plumbing).

Phase 4 — Reporting externalization

4.1

canopy-reporting/reporting/medicaid.rs — move T-MSIS coverage-group map (50+), disability / dual-eligible allowlists, CMS-416 age bands, fiscal-quarter mapping, EPSDT max age into new rulesets/federal/tmsis-coverage-group-map.json + rulesets/federal/cms-416-2026.json with full citations.

Done (2026-04-20) — deleted 4 Rust match / const arms totaling ~80 lines of CMS-spec data: coa_to_coverage_group (37 arms + default), is_disability_coa (16-entry matches! arm), is_dual_eligible_coa (3-entry arm), AGE_GROUPS const (7 tuples), and the EPSDT age >= 21 gate. New services/canopy-reporting/src/params.rs carries a ReportingParameterTable that loads two new federal files at startup: rulesets/federal/tmsis-coverage-group-map.json (coverage_groups map with per-COA description / track / code + disability_coas.members + dual_eligible_coas.members + default_coverage_group) and rulesets/federal/cms-416-2026.json (age_bands[] + epsdt_max_age). Loaded once at startup in main.rs and injected as an axum::Extension<Arc<ReportingParameterTable>>; generate_medicaid_tmsis and generate_medicaid_cms416 handlers pull it by extension. extract_tmsis / extract_cms416 take &ReportingParameterTable explicitly. All 11 pre-existing medicaid::tests::coa_mapping_* + disability_detection + dual_eligible_detection tests updated to call p.coverage_group(…​) / p.is_disability_coa(…​) / p.is_dual_eligible_coa(…​) against a real table loaded from disk (test_params() helper), plus a new params::tests::load_reporting_params covering all four structures. Added 2 citations (tmsis-coverage-group-map → CMS T-MSIS Data Dictionary v2.3; cms-416-2026 → CMS-416 Instructions + §1905(r)) in rulesets/federal/citations.toml. Fiscal-quarter mapping left inline per plan (operational / accounting-adjacent, 31 USC §1102 applies but isn’t a jurisdictional knob). 38/38 canopy-reporting tests pass; cargo xtask rules check 12 compiled + 10/10 fixtures; cargo xtask validate all green (340s). Tenth of 17 steps; completes Phase 4.

Phase 5 — Single-service residuals

5.1

SNAP budgeting factors Rust-side. canopy-snap/src/deductions.rs:82/122/181 — 20% / 30% / 50% → extend SnapParameterTable to load snap-budgeting-factors.json.

Done (2026-04-20) — 3 dec!(0.20) / dec!(0.50) / dec!(0.30) call sites in services/canopy-snap/src/deductions.rs consumed from DeductionParams instead. DeductionParams grew earned_income_deduction_pct + shelter_half_pct fields (the 30% in calculate_allotment is now the final au_net_income_pct positional arg). The source of truth — rulesets/federal/snap-budgeting-factors.json — was already plumbed through SnapParameterTable by Phase 3.3, so this step is a Rust-side consumer swap with no jurisdiction / citation additions. 135/135 canopy-snap tests pass (7 updated to pass dec!(0.30) explicitly for the allotment percent). cargo xtask policy audit-literals violations 10 → 7 (remaining: 4 pay-period 26/52 in determine.rs → Phase 5.3; 2 GRG 100/4 in grg_handlers.rs → Phase 5.6; 1 proration 30 → Phase 5.6). Eleventh of 17 steps; starts Phase 5.

5.2

SNAP ABAWD time-limit constants. canopy-snap/src/abawd.rs:107/211/218/224/237 — 36-month window, 3-month limit, 3-consecutive regaining → [snap.abawd] with 7 CFR 273.24 citation.

Done (2026-04-20) — 5 ABAWD time-constant literals in services/canopy-snap/src/abawd.rs eliminated. SnapParameterTable grew abawd_time_limit_months: i32 and abawd_window_months: u32 (loaded from the pre-existing [snap.abawd] time_limit_months = 3 / window_months = 36 jurisdiction.toml keys with .context(…​)? on missing — no silent defaults). create_tracking(pool, person_id, household_id, window_start, window_months) now takes the window explicitly instead of + chrono::Months::new(36). check_time_limit(activities, qualifying_hours, time_limit_months) and check_regaining(activities, qualifying_hours, time_limit_months) both gained a time_limit_months: i32 positional arg replacing the hardcoded >= 3, 3 - non_qualifying_months, and consecutive_qualifying >= 3 literals. services/canopy-snap/src/api/abawd_handler.rs pulls abawd_time_limit_months from the SnapParameterTable extension alongside abawd_qualifying_hours. 11 unit-test call sites updated to pass 3 explicitly. Both citations (snap.abawd.time_limit_months, snap.abawd.window_months) pre-existed with 7 CFR 273.24 references — no citations.toml additions. 135/135 canopy-snap tests pass. Twelfth of 17 steps.

5.3

Pay-period conversions. canopy-snap/src/determine.rs:75-76/103-104 + verification.rs:125/174 — 52/12, 26/12, /3 → SnapParameterTable from snap-budgeting-factors.json.

Done (2026-04-20) — 8 hardcoded calendar literals eliminated (4 × Decimal::from(26)/Decimal::from(52) in IncomeRecord::monthly_amount + ExpenseRecord::monthly_amount; the /3.0 quarterly-wages divisor in verification.rs:127; the w * 52.0 / 12.0 UI benefit conversion in verification.rs:183; plus 6 stale Decimal::from(12) allowlist entries now removed). Added new pay_periods section to rulesets/federal/snap-budgeting-factors.json with exact integer pay-period counts (weekly_per_year=52, biweekly_per_year=26, semi_monthly_per_year=24, months_per_year=12, months_per_quarter=3) plus a file-level citation in rulesets/federal/citations.toml (7 CFR 273.10(c)(2), PAMMS 3605). SnapParameterTable grew a pay_periods: PayPeriods field; SnapParameters does too (Serialize-derived) so for_household propagates it. IncomeRecord::monthly_amount and ExpenseRecord::monthly_amount now each take &PayPeriods and also gained a "semi_monthly" branch that was previously missing. VerificationRequest grew pay_periods: &'a PayPeriods; store_and_compare threads it through. determine_handler passes &params.pay_periods. 135/135 canopy-snap tests pass (13 test call sites updated to pass &pp()). cargo xtask policy audit-literals violations 7 → 3 (remaining: 2 GRG 100/4 in grg_handlers.rs + 1 proration 30 — all Phase 5.6). Thirteenth of 17 steps.

5.4

Application-processing deadlines. canopy-applications/src/api/mod.rs:382-389 — SNAP 30/7, TANF 30, Medicaid 45, CHIP 45 → [shared.application_processing] with 7 CFR 273.2(g) / 45 CFR 435.912 / 42 CFR 457.340 citations. Remove duplicate +7 in canopy-web/src/api/applications.rs:93.

Done (2026-04-20) — 6 hardcoded deadlines eliminated. New [shared.application_processing] section in rulesets/georgia/jurisdiction.toml with snap_standard_days = 30 / snap_expedited_days = 7 / tanf_days = 30 / medicaid_days = 45 / chip_days = 45. 5 new citations in citations.toml (7 CFR 273.2(g)(1) / 7 CFR 273.2(i)(3) / 45 CFR 260.20 / 45 CFR 435.912(c)(3)(ii) / 42 CFR 457.340(d)). New ProcessingDeadlines struct + load_processing_deadlines() in services/canopy-applications/src/params.rs; main.rs loads at startup and injects as an axum::Extension<Arc<ProcessingDeadlines>>. compute_processing_deadline now takes &ProcessingDeadlines — the literal match program { "snap" ⇒ 30 … } arm is gone; callers in create_application and the expedited-screen update path both read from the extension. The canopy-web duplicate chrono::Duration::days(7) in services/canopy-web/src/api/applications.rs:93 was deleted — canopy-web now pulls the expedited SNAP deadline straight out of the SNAP program row canopy-applications already stores (programs[?program=='snap'].processing_deadline), single source of truth. 5 compute_processing_deadline test call sites updated with a fixture_deadlines() helper. 62/62 canopy-applications + canopy-web tests pass. cargo xtask validate: all green (336s). Fourteenth of 17 steps.

5.5

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.

Done (2026-04-20) — food-package decision tree (category × age-breakpoint × breastfeeding → package key) externalized to a new assignment_rules section in the existing rulesets/federal/wic-food-packages-2026.json (single-file home for all WIC food-package data) with infant_age_breakpoint_months=6, default_package_key="child_1_4", and 8 rules covering infants (age < 6 / age ≥ 6 × breastfeeding on/off), breastfeeding / pregnant / postpartum / child categories. Two new public structs in services/canopy-wic/src/params.rsFoodPackageAssignment + FoodPackageAssignmentRule — loaded alongside the existing food-packages / certification-periods tables with .with_context(…​)? on missing fields (never silent). assign_food_package now walks assignment.rules first-match-wins, treating omitted age_under_breakpoint / breastfeeding_fully as wildcards, and falls back to default_package_key. No JDM ruleset was created — food-package assignment is a post-eligibility configuration call, not an eligibility decision per ADR-003; a flat federal-data file matches the pattern used for Phase 4.1’s T-MSIS / CMS-416 tables. Errata: plan wording said "per ADR-003 (JDM)" — this was reinterpreted as "per ADR-011 (externalize the policy data)" since ADR-003 applies to eligibility logic specifically. 14/14 canopy-wic tests pass (8 existing assertions still green against the new data-driven path). cargo xtask validate all green (338s). Fifteenth of 17 steps.

5.6

Small-scope parameter externalizations (one MR for these grouped, or split if reviewers prefer): CAPS age gates (13 / 19) → [caps]; GRG amounts ($100 MSP, ×4 CRISP) → [tanf.grg]; TANF cert period (6) → [tanf]; SNAP elderly threshold (60) → [shared].

Done (2026-04-21) — 7 hardcoded literals externalized in one MR: CAPS age gates (13 / 19) in canopy-caps/src/determine.rs:89-93, GRG MSP / CRISP amounts (Decimal::from(100) / Decimal::from(4)) in canopy-tanf/src/api/grg_handlers.rs:40-44, TANF certification period (chrono::Months::new(6)) in canopy-tanf/src/determine.rs:205, SNAP elderly threshold (computed_age >= 60) in canopy-eligibility/src/orchestrator.rs:130, and the proration denominator (Decimal::from(30u32)) in canopy-tanf/src/proration.rs:30 (parameterized via function arg — no production consumer, so no TanfParameterTable field). Added [shared] section to rulesets/georgia/jurisdiction.toml with elderly_age_threshold = 60; [caps] grew child_age_max = 13 and special_needs_child_age_max = 19; [tanf] grew certification_months = 6 and proration_month_days = 30; [tanf.grg] grew msp_monthly_amount_cents = 10000 and crisp_family_maximum_multiplier = 4. 7 new citations added (7 CFR 273.1(b)(7); 45 CFR 98.20(a)(1)(i)/(ii); PAMMS 1815/1105/1210 × 2). CapsParameterTable / TanfParameterTable grew matching accessors with .with_context(…​)?. canopy-eligibility: new orchestrator::load_elderly_age_threshold() helper + DetermineConfig.elderly_age_threshold field; main.rs loads at startup + injects as api::handlers::ElderlyAgeThreshold axum extension; 5 test DetermineConfig sites updated to pass elderly_age_threshold: 60. cargo xtask policy audit-literals: violations 3 → 0 (clean) — Phase 2.1 baseline of 10 now fully eliminated. 125/125 canopy-caps + canopy-tanf + canopy-eligibility tests pass; cargo xtask validate all green (301s). Sixteenth of 17 steps.

5.7

Missing citations for values already in jurisdiction.toml: caps.copayment_tiers, caps.default_provider_rate_cents_per_hour, wic.food_packages, wic.certification_periods_months. Add [citations.*] blocks. Verifies 1.4 by deleting one and watching CI fail.

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 rulesets/federal/wic-food-packages-2026.json, not jurisdiction.toml, so they never showed up in the audit): caps.copayment_tiers (DECAL sliding-fee schedule + 45 CFR 98.45), caps.default_provider_rate_cents_per_hour (DECAL market-rate survey + 45 CFR 98.45(b)), tanf.wpr.all_family_target_pct / two_parent_target_pct / caseload_reduction_credit_pct / work_requirement_age_min / work_requirement_age_max / work_requirement_single_parent_hours / work_requirement_two_parent_hours (45 CFR 261.21 / 261.23 / 261.41-44 / 261.30 / 261.31 / 261.32 + PAMMS 1349). cargo xtask policy audit: 205/201 keys cited — clean, zero errors. All three remaining Phase 1 CI gates flipped from allow_failure: true to blocking: adr-011-literal-audit (Phase 5.6 finished the literal sweep — 0 violations), adr-011-ruleset-input-audit (Phase 3 finished the JDM plumbing — 0 violations with 2 allowlist entries), adr-011-policy-audit (Phase 5.7 finished the citation backfill — 0 violations). Every ADR-011 CI guard is now blocking. Seventeenth of 17 steps; completes the ADR-011 Hardcoded Policy Values Sweep.

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.rs pattern 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.toml entries for values already in jurisdiction.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., SelfEmploymentNet disregard 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.json has 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 audit and cargo xtask rules check already 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 — add adr-011-grep-guard in Step 1.2 alongside existing compliance-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 — extend MedicaidParameterTable to also hold a CrossProgramParameterTable reference; consume from it.

  • services/canopy-medicaid/src/express_lane.rs:54,70,72 — same.

  • services/canopy-snap/src/tsnap.rs (TSNAP subscriber) — consume via SnapParameterTable extension.

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

services/canopy-applications/src/params.rs:25,29

15000 / 10000 (expedited income / resource thresholds)

services/canopy-renewals/src/params.rs:34,38,42,46,61

22 (elderly-disabled threshold), 6 (interim contact months), 75 (renewal notice days), 30 (second notice days), 130 (gross income %FPL)

services/canopy-enrollment/src/main.rs:47,51,55

7 (expedited issuance days), 30 (standard issuance days), 365 (expungement days) — note: confirm 365 vs 274 days per 7 USC §2016(h)(9)

services/canopy-appeals/src/config.rs:22,38-42,92-95

30 (ADH notice days), 12 / 24 (IPV first / second offense penalties), trafficking + 3rd-offense default-true flags

services/canopy-caps/src/params.rs:74,78,82,86,113-119

50 / 85 (SMI thresholds), 24 (activity hours), 12 (authorization period), copayment tier fallback table

services/canopy-wic/src/params.rs:93,103,57,146,184

185 (FPL threshold), adjunctive program list fallback, 5500.0 (FPL increment), 12 (cert period), "V" (default food package)

services/canopy-snap/src/params.rs:170,176

$23 (minimum benefit), 2 (minimum benefit household max size)

services/canopy-snap/src/verification.rs:23,272

Decimal::ONE_HUNDRED (IEVS discrepancy flag threshold, duplicated)

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

rulesets/georgia/tanf-work-requirements.json

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)

wpr_work_age_min / wpr_work_age_max, infant_exemption_months_max, young_child_months_max, wpr_single_parent_hours / wpr_two_parent_hours / wpr_core_hours / wpr_single_parent_young_child_hours

rulesets/georgia/tanf-eligibility.json

>= 48 state time limit (lines 49, 57)

state_time_limit_months

rulesets/georgia/medicaid-magi.json

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)

child_age_newborn_max, child_age_pc_max, child_age_c19_max, former_foster_care_max_age, whm_min_age / whm_max_age, p4hb_min_age / p4hb_max_age

rulesets/georgia/medicaid-non-magi.json

Age 65 (ABD, lines 37, 43, 49, 127); age 19 (TEFRA, line 109); 30-day hospital LOS (line 121); 18-21 (Chafee, line 169)

abd_min_age, tefra_max_age, hospital_los_days_threshold, chafee_min_age / chafee_max_age

rulesets/georgia/chip-eligibility.json

< 19 (CHIP gate, line 31)

chip_max_age

rulesets/georgia/snap-eligibility.json

0.20 (earned-income deduction, line 157); 0.50 (shelter half-income test, line 165); 0.30 (allotment contribution, line 228)

earned_income_pct, shelter_half_pct, allotment_contribution_pct

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.json OR 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)

xtask/src/policy.rs, xtask/src/rules.rs, .gitlab-ci.yml, compliance/adr-011-unwrap-allowlist.toml, compliance/adr-011-grep-allowlist.toml, compliance/adr-011-ruleset-input-allowlist.toml

Shared-crate constant removal (Phase 2)

crates/canopy-reference/src/cross_program.rs, services/canopy-medicaid/src/tma.rs, services/canopy-medicaid/src/express_lane.rs, services/canopy-snap/src/tsnap.rs, every services/*/src/params.rs, services/canopy-snap/src/verification.rs

Rulesets (Phase 3)

rulesets/georgia/tanf-work-requirements.json, rulesets/georgia/tanf-eligibility.json, rulesets/georgia/medicaid-magi.json, rulesets/georgia/medicaid-non-magi.json, rulesets/georgia/chip-eligibility.json, rulesets/georgia/snap-eligibility.json

New federal rulesets (Phases 4, 5.5)

rulesets/federal/tmsis-coverage-group-map.json, rulesets/federal/cms-416-2026.json, rulesets/federal/wic-food-package-assignment.json

Reporting (Phase 4)

services/canopy-reporting/src/reporting/medicaid.rs

Consumer Rust code (Phases 3 + 5)

services/canopy-tanf/src/determine.rs, services/canopy-medicaid/src/{determine,au_composition,tma,express_lane}.rs, services/canopy-snap/src/{deductions,abawd,determine,verification,tsnap}.rs, services/canopy-applications/src/api/mod.rs, services/canopy-web/src/api/applications.rs, services/canopy-wic/src/params.rs, services/canopy-caps/src/determine.rs, services/canopy-tanf/src/api/grg_handlers.rs, services/canopy-eligibility/src/orchestrator.rs

Policy data

policy/georgia/jurisdiction.toml, policy/georgia/citations.toml, rulesets/federal/citations.toml

Documentation

docs/modules/ROOT/pages/roadmap.adoc, this plan’s Status table (per step), CHANGELOG.adoc (one entry per MR)

Verification

Per-MR verification

Each step includes:

  1. cargo nextest run -p <affected service> — existing + any new tests pass.

  2. cargo fmt --all --check + cargo clippy -p <service> --all-targets — -D warnings.

  3. cargo xtask policy audit — green (Phase 1 items strengthen this; every later step must continue to satisfy it).

  4. cargo xtask rules check — green (includes lint-inputs after Step 1.3).

  5. cargo xtask validate — full battery green.

  6. 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.

  7. 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 new context.thresholds.* inputs.

  8. 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)

  1. cargo xtask policy audit — zero violations across the repo.

  2. rg --pcre2 '\.unwrap_or\(\s*[0-9]+\s*\)' services//src/*/params.rs — zero matches (except allowlisted).

  3. rg --pcre2 '\b(Decimal::from|dec!)\s*\(\s*[0-9]+\s*\)' services//src/*/*.rs — only allowlisted matches.

  4. Run one fresh end-to-end determination per program against seed data; compare to pre-sweep baseline; byte-identical or justified difference.

  5. 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== Unreleased entry under === Changed (or === Added for 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.adoc describing 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 emits MissingCitation for any key without a citation.

  • Loop at lines 189-226 walks manifest.citations and 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 in jurisdiction.toml for 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’s ParameterTable. Stronger than the lint-inputs pattern (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.toml with a citations.toml entry, (b) in rulesets/federal/*.json with a _citation field, or (c) in an allowlist with a written reason.

  • templates/applications/process.html FPL 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):

  • #412 — Consolidated [shared.timing] section in jurisdiction.toml

  • #413 — Formalise ADR-011 ATO evidence statement

  • templates/applications/process.html FPL display strings — cosmetic UX concern, not a policy-trace violation. Deferred indefinitely.

Edit this page · default