Plan: Frequency-Normalization Foundation (#861, epic &63)

On this page
NOTE

Implements #861 under epic &63, governed by ADR-034 (the orchestrator builds each program’s complete, typed determine input) and ADR-011 / ADR-006 (pay-period factors live in the cited federal ruleset, never hardcoded).

Status

Step Description Status

1. Shared converter

canopy_reference::money::{Frequency, MonthlyFactors, to_monthly} — one taxonomy reconciling every spelling (monthly/weekly/biweekly|bi_weekly/semimonthly|semi_monthly/annual|annually|yearly) + the conversion arithmetic (mirrors SNAP’s MonthlyAmount exactly, no rounding). Ships no factor values; MonthlyFactors::from_federal_factors_json parses the cited snap-budgeting-factors.json pay_periods via an i64 raw struct (the workspace’s rust_decimal serde-str means plain Decimal deserialize would expect JSON strings).

Done (2026-06-15) — 4 unit tests.

2. Orchestrator normalization seam

The eligibility orchestrator loads MonthlyFactors at startup (injected via DetermineConfig, mirroring load_elderly_age_threshold) and normalizes household income/expenses to monthly in fetch_household_context before dispatch — typed Income/Expense round-trip (no untyped-JSON munging), stamping frequency="monthly" only when conversion happened; an unrecognized frequency is left untouched + warned (never silently re-labeled).

Done (2026-06-15) — 3 unit tests; zero fixture impact (all orchestrator-reachable fixtures use monthly).

3. Retire duplicate converters

SNAP MonthlyAmount (both impls) and Medicaid-ELE monthly_income_cents delegate to the shared converter (SNAP builds MonthlyFactors from its existing PayPeriods; ELE from MedicaidParameterTable, loaded from the same federal JSON). ELE’s unreachable daily branch dropped; the now-stale Decimal::from(12) allowlist entry removed. SNAP byte-identical; ELE i64-cents identical.

Done (2026-06-15).

4. canopy-web f64 converter

services/canopy-web/src/api/applications.rs::monthly_equivalent (display-only f64) — retire onto the shared taxonomy. Needs rust_decimal on canopy-web + relaxing a bit-exact f64 test.

Deferred (#863 — cosmetic, not a determination path)

Design — decisions

  • Factors are policy data, not code constants. The pay-period counts (52/26/24/12) are calendar constants in rulesets/federal/snap-budgeting-factors.json per ADR-006/ADR-011 (the allowlist’s own guidance says pay-period conversion factors live there). canopy_reference::money ships the taxonomy + arithmetic only; every consumer (SNAP, the orchestrator, Medicaid) loads the factors from that JSON. audit-literals scanning only services/ + crates/canopy-contracts-* (#657) is not a license to hardcode them in the other crates/.

  • Normalization at the orchestrator seam (ADR-034). ADR-034 makes the orchestrator responsible for each program’s complete typed input. The medicaid/tanf contracts drop frequency, so the conversion must happen before deserialization — in the orchestrator, not the program. SNAP receives frequency="monthly" → its (delegating) MonthlyAmount is a passthrough → byte-identical.

  • Recognized-vs-passthrough is explicit. The free to_monthly passes an unrecognized frequency through (SNAP’s historical default); callers that must not mislabel a record (the orchestrator seam, ELE) use Frequency::parse + match and warn on the unknown branch.

Verification

  • cargo nextest run -p canopy-reference -p canopy-eligibility -p canopy-snap -p canopy-medicaid (converter + seam + delegations; existing suites unchanged).

  • cargo xtask policy audit-literals clean (no new literals; stale ELE entry removed); quality-budgets B3a at floor (typed round-trip, no new serde_json::Value).

  • SNAP byte-identity: the SNAP MonthlyAmount suite + a weekly-income check yield the same monthly as before (same federal factors).

Edit this page · default