Plan: Cross-Program Functional Testing
On this page
- Status
- Context
- Scope
- Design
- Steps
- Step 0: Federal parameter file
- Step 1: TSNAP subscriber completion
- Step 2: Express Lane event publishing
- Step 2b: FPL accessor for SNAP and TANF param tables
- Step 3: Express Lane subscriber completion
- Step 4: wait_for_event() helper
- Step 5: E2E test — TSNAP certification
- Step 6: E2E test — TMA coverage + GET endpoint
- Step 7: E2E test — Express Lane
- Step 8: E2E test — negative cases
- Files Touched
- Verification
- Documentation Updates
- Errata
Status
| Step | Description | Status |
|---|---|---|
0 |
Federal parameter file: |
Done — |
1 |
TSNAP subscriber completion: migration, store, handler, GET endpoint |
Done — Migration |
2 |
Express Lane event publishing from canopy-snap and canopy-tanf ( |
Done — |
2b |
FPL accessor for SNAP and TANF param tables |
Done (N/A — design deviation) — Plan called for |
3 |
Express Lane subscriber completion in canopy-medicaid |
Done — |
4 |
|
Done (2026-04-27) — Added to |
5 |
E2E test: TSNAP certification created from TANF closure |
Done — |
6 |
E2E test: TMA coverage created + GET endpoint verification |
Done — |
7 |
E2E test: Express Lane child Medicaid/PeachCare determination |
Done — |
8 |
E2E test: negative cases (voluntary closure, no children, over-income) |
Done — |
Branch: feature/cross-program-functional-testing
Context
The cross-program integration framework (plan: cross-program-integration.adoc) delivered the domain logic — tsnap.rs, tma.rs, express_lane.rs, and the event constants in canopy-reference::cross_program — plus RabbitMQ subscriber stubs in canopy-snap and canopy-medicaid main.rs. However, several subscriber bodies are incomplete (marked TODO), no service publishes snap.application_approved or tanf.application_approved, there is no database table for TSNAP certifications, and there are zero E2E tests exercising the multi-service event chains.
This plan closes those gaps. It completes the subscriber implementations so that events flowing through RabbitMQ produce real database records, adds the missing event publishing, and delivers E2E integration tests that prove the chains work against a running devstack.
All cross-program parameter thresholds (TSNAP months, TMA months, Express Lane FPL percentages, TMA QRF schedule) are currently hardcoded in canopy-reference::cross_program as Rust constants. Per ADR-011, every parameter derived from federal regulation must be loaded from a versioned parameter file with citations. Step 0 creates the authoritative federal parameter file, and a future follow-up will migrate the canopy-reference constants to read from it. This plan intentionally does not remove the constants from canopy-reference yet — that migration is tracked as an ADR-011 compliance follow-up and noted in the documentation section.
Federal regulatory basis:
-
TSNAP: 7 CFR 273.26 / PAMMS 3704
-
TMA: 42 CFR 435.112 / PAMMS 2166
-
Express Lane: 42 CFR 435.1102 / PAMMS 2069
-
TCOS: 7 CFR 273.2(j) / PAMMS 3210
Scope
In scope:
-
Federal parameter file
rulesets/federal/cross-program-2026.jsonwith full citation metadata -
TSNAP: database migration (
snap_tsnap_certificationstable), store CRUD, subscriber completion incanopy-snap/src/main.rs,GET /v1/tsnap/{household_id}endpoint -
Express Lane event publishing:
snap.application_approvedfrom canopy-snap,tanf.application_approvedfrom canopy-tanf -
FPL accessor:
fpl_100_monthly()method onSnapParameterTableandTanfParameterTable(needed for Express Lane context payloads) -
Express Lane subscriber completion in canopy-medicaid (parse children’s ages + income from event payload, call
check_express_lane()) -
wait_for_event()async helper in canopy-test-lib for E2E tests that depend on event propagation -
4 E2E integration test modules (TSNAP, TMA + GET, Express Lane, negative cases)
Out of scope:
-
Migrating
canopy-reference::cross_programconstants to load from the new parameter file (ADR-011 follow-up) -
TCOS categorical eligibility E2E tests (requires canopy-tanf TCOS endpoint not yet implemented)
-
LIHEAP → SUA linkage tests (LIHEAP flag already wired; no new code needed)
-
Mandatory referral notice generation (depends on Typst template work in canopy-notices)
Design
TSNAP table schema
The snap_tsnap_certifications table stores TSNAP records created when canopy-snap’s subscriber processes a tanf.case_closed event with an employment-related reason.
CREATE TABLE IF NOT EXISTS snap_tsnap_certifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID NOT NULL,
certification_start_date DATE NOT NULL,
certification_end_date DATE NOT NULL,
frozen_benefit_amount NUMERIC(10,2) NOT NULL,
pre_closure_snap_allotment NUMERIC(10,2) NOT NULL,
tanf_grant_removed NUMERIC(10,2) NOT NULL,
reporting_required BOOLEAN NOT NULL DEFAULT FALSE,
sanctions_applicable BOOLEAN NOT NULL DEFAULT FALSE,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_tsnap_household ON snap_tsnap_certifications (household_id);
CREATE INDEX idx_tsnap_status ON snap_tsnap_certifications (status)
WHERE status = 'active';
TsnapCertificationRow
Store model mapping for the snap_tsnap_certifications table:
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct TsnapCertificationRow {
pub id: Uuid,
pub household_id: Uuid,
pub certification_start_date: NaiveDate,
pub certification_end_date: NaiveDate,
pub frozen_benefit_amount: Decimal,
pub pre_closure_snap_allotment: Decimal,
pub tanf_grant_removed: Decimal,
pub reporting_required: bool,
pub sanctions_applicable: bool,
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
Express Lane event payload shape
The snap.application_approved and tanf.application_approved events must carry the data that canopy-medicaid’s Express Lane subscriber needs:
{
"household_id": "uuid",
"application_id": "uuid",
"household_size": 3,
"verified_monthly_income": "1500.00",
"children_ages": [5, 8],
"fpl_100_monthly": "2220.83",
"source_program": "snap"
}
The fpl_100_monthly field is the 100% FPL monthly amount for the household size, computed by the param table’s fpl_100_monthly() accessor. The Express Lane subscriber in canopy-medicaid constructs an ExpressLaneContext from this payload and calls check_express_lane().
ExpressLaneContext (existing)
The ExpressLaneContext struct in services/canopy-medicaid/src/express_lane.rs is already implemented:
pub struct ExpressLaneContext {
pub household_id: HouseholdId,
pub source_program: String,
pub verified_monthly_income: Decimal,
pub household_size: u32,
pub children_ages: Vec<u32>,
pub fpl_100: Decimal,
}
wait_for_event() helper
A test helper that polls for a condition with backoff, used by E2E tests that publish an event to one service and verify a side effect in another:
pub async fn wait_for_event<F, Fut>(
description: &str,
max_attempts: u32,
interval_ms: u64,
check: F,
) -> bool
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = bool>,
Returns true if check() returns true within max_attempts * interval_ms milliseconds. Logs a warning and returns false otherwise.
Federal parameter file
The rulesets/federal/cross-program-2026.json file centralizes all cross-program thresholds with citation metadata per ADR-011:
{
"_comment": "Cross-program federal parameters for FY2026",
"_source": "7 CFR 273.26, 42 CFR 435.112, 42 CFR 435.1102",
"_fiscal_year": "2026",
"_effective_date": "2025-10-01",
"tsnap": {
"certification_months": 5,
"_citation": "7 CFR 273.26(a) / PAMMS 3704"
},
"tma": {
"coverage_months": 12,
"phase_1_months": 6,
"phase_2_income_limit_pct_fpl": 205,
"qrf_due_months": [4, 7, 10],
"_citation": "42 CFR 435.112 / PAMMS 2166"
},
"express_lane": {
"medicaid_fpl_pct": 235,
"peachcare_fpl_pct": 247,
"max_age": 19,
"_citation": "42 CFR 435.1102 / PAMMS 2069"
}
}
crates/canopy-reference/src/cross_program.rs (TSNAP_CERTIFICATION_MONTHS, EXPRESS_LANE_MEDICAID_FPL_PCT, etc.) currently duplicate these values as Rust constants. Per ADR-011, a follow-up task must migrate all program services to load these values from the federal parameter file at startup, matching the pattern used by SnapParameterTable::load() and MedicaidParameterTable::load(). Until that migration, the JSON file is authoritative and the Rust constants must be kept in sync manually.
Steps
Step 0: Federal parameter file
Files: rulesets/federal/cross-program-2026.json
Create the federal parameter file with the JSON content shown in the Design section. Follow the existing pattern in rulesets/federal/fpl-2026.json for metadata fields (_comment, _source, _fiscal_year, _effective_date).
Each top-level section (tsnap, tma, express_lane) includes a _citation field tracing the value to its federal regulatory authority and the corresponding PAMMS section.
Step 1: TSNAP subscriber completion
Files: services/canopy-snap/migrations/20260415000000_create_snap_tsnap_certifications.sql, services/canopy-snap/src/store/mod.rs, services/canopy-snap/src/store/models.rs, services/canopy-snap/src/api.rs, services/canopy-snap/src/main.rs
1a: Migration
Create services/canopy-snap/migrations/20260415000000_create_snap_tsnap_certifications.sql with the DDL from the Design section.
1b: Store model
Add TsnapCertificationRow to services/canopy-snap/src/store/models.rs:
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct TsnapCertificationRow {
pub id: Uuid,
pub household_id: Uuid,
pub certification_start_date: NaiveDate,
pub certification_end_date: NaiveDate,
pub frozen_benefit_amount: Decimal,
pub pre_closure_snap_allotment: Decimal,
pub tanf_grant_removed: Decimal,
pub reporting_required: bool,
pub sanctions_applicable: bool,
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
1c: Store functions
Add to services/canopy-snap/src/store/mod.rs:
pub async fn create_tsnap_certification(
pool: &PgPool,
cert: &crate::tsnap::TsnapCertification,
) -> Result<models::TsnapCertificationRow, sqlx::Error> {
sqlx::query_as::<_, models::TsnapCertificationRow>(
"INSERT INTO snap_tsnap_certifications
(household_id, certification_start_date, certification_end_date,
frozen_benefit_amount, pre_closure_snap_allotment, tanf_grant_removed,
reporting_required, sanctions_applicable)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *",
)
.bind(cert.household_id)
.bind(cert.certification_start_date)
.bind(cert.certification_end_date)
.bind(cert.frozen_benefit_amount)
.bind(cert.pre_closure_snap_allotment)
.bind(cert.tanf_grant_removed)
.bind(cert.reporting_required)
.bind(cert.sanctions_applicable)
.fetch_one(pool)
.await
}
pub async fn find_active_tsnap(
pool: &PgPool,
household_id: HouseholdId,
) -> Result<Option<models::TsnapCertificationRow>, sqlx::Error> {
sqlx::query_as::<_, models::TsnapCertificationRow>(
"SELECT * FROM snap_tsnap_certifications
WHERE household_id = $1 AND status = 'active'
ORDER BY created_at DESC LIMIT 1",
)
.bind(household_id)
.fetch_optional(pool)
.await
}
1d: Complete subscriber handler
Replace the TODO comment in the TSNAP subscriber in services/canopy-snap/src/main.rs with real logic. The verified signature of build_tsnap_certification is:
pub fn build_tsnap_certification(
household_id: HouseholdId,
closure_date: NaiveDate,
pre_closure_snap_allotment: Decimal,
tanf_grant_amount: Decimal,
) -> TsnapCertification
The subscriber should:
-
Parse
household_id,closure_date,tanf_grant_amountfrom thetanf.case_closedevent payload (use the existingTanfCaseClosedPayloadstruct). -
Look up the current SNAP determination for the household to get
pre_closure_snap_allotment(querysnap_determinationsbyhousehold_idwithstatus = 'approved'ordered bydetermined_at DESC). -
Call
build_tsnap_certification()with the parsed values. -
Call
store::create_tsnap_certification()to persist. -
Publish
snap.tsnap_createdevent (using the existingTSNAP_CREATEDconstant).
Step 2: Express Lane event publishing
Files: services/canopy-snap/src/events.rs, services/canopy-snap/src/determine.rs, services/canopy-tanf/src/events.rs, services/canopy-tanf/src/determine.rs
2a: SNAP application_approved event
Add to services/canopy-snap/src/events.rs:
/// Publish snap.application_approved event for Express Lane (42 CFR 435.1102).
/// Carries household demographics needed by canopy-medicaid's Express Lane subscriber.
/// No PII, income amounts only (not SSN/FTI).
pub async fn publish_application_approved(
publisher: &Publisher,
household_id: HouseholdId,
application_id: SnapApplicationId,
household_size: u32,
verified_monthly_income: Decimal,
children_ages: &[u32],
fpl_100_monthly: Decimal,
) {
let envelope = EventEnvelope::new(
SOURCE,
"snap.application_approved",
serde_json::json!({
"household_id": household_id.to_string(),
"application_id": application_id.to_string(),
"household_size": household_size,
"verified_monthly_income": verified_monthly_income.to_string(),
"children_ages": children_ages,
"fpl_100_monthly": fpl_100_monthly.to_string(),
"source_program": "snap",
}),
);
if let Err(e) = publisher.publish(&envelope).await {
tracing::error!("failed to publish snap.application_approved: {e}");
}
}
Call this from the determination handler in services/canopy-snap/src/determine.rs after a successful approval, passing the household context and FPL value from the param table.
Step 2b: FPL accessor for SNAP and TANF param tables
Files: services/canopy-snap/src/params.rs, services/canopy-tanf/src/params.rs
Add a fpl_100_monthly() method to SnapParameterTable that computes 100% FPL monthly from the net income limits (which are 100% FPL). The net income limits are already loaded from snap-income-limits-2026.json:
impl SnapParameterTable {
/// Return 100% FPL monthly for the given household size.
/// Uses the net income limits (which ARE 100% FPL monthly).
pub fn fpl_100_monthly(&self, household_size: u32) -> Decimal {
let size = household_size.max(1);
if size <= 8 {
*self.net_income_limits.get(&size).unwrap_or(&Decimal::ZERO)
} else {
let base = *self.net_income_limits.get(&8).unwrap_or(&Decimal::ZERO);
base + self.net_income_additional_person
* Decimal::from(size - 8)
}
}
}
Add an equivalent accessor to TanfParameterTable in canopy-tanf. If canopy-tanf does not already load FPL data, add an fpl_monthly_by_hh field loaded from fpl-2026.json (following the pattern in services/canopy-medicaid/src/params.rs).
Step 3: Express Lane subscriber completion
Files: services/canopy-medicaid/src/main.rs
Replace the TODO comment in the Express Lane subscriber with real logic:
-
Parse the event payload fields:
household_id,verified_monthly_income,household_size,children_ages,fpl_100_monthly,source_program. -
Construct an
ExpressLaneContext:
let ctx = express_lane::ExpressLaneContext {
household_id: HouseholdId::from(household_id),
source_program,
verified_monthly_income,
household_size,
children_ages,
fpl_100: fpl_100_monthly,
};
-
Call
express_lane::check_express_lane(&ctx). -
If the result is
MedicaidEligibleorPeachCareEligible, log the result. Creating a Medicaid application record via the store layer is optional at this stage but recommended for the E2E test to verify. -
If
NoQualifyingChildrenorNotEligible, log and skip.
Step 4: wait_for_event() helper
Files: crates/canopy-test-lib/src/events.rs (new), crates/canopy-test-lib/src/lib.rs
Create crates/canopy-test-lib/src/events.rs:
// SPDX-License-Identifier: AGPL-3.0-or-later
//! Event propagation helpers for cross-service integration tests.
/// Poll a condition function until it returns true or max attempts are exhausted.
///
/// Used by E2E tests that publish an event to one service and need to verify
/// a side effect in another service (e.g., TSNAP certification created after
/// tanf.case_closed event).
///
/// Returns `true` if the condition was met within the timeout window.
pub async fn wait_for_event<F, Fut>(
description: &str,
max_attempts: u32,
interval_ms: u64,
check: F,
) -> bool
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = bool>,
{
for attempt in 1..=max_attempts {
if check().await {
tracing::info!(
description,
attempt,
"wait_for_event: condition met"
);
return true;
}
tokio::time::sleep(std::time::Duration::from_millis(interval_ms)).await;
}
tracing::warn!(
description,
max_attempts,
"wait_for_event: condition not met within timeout"
);
false
}
Add pub mod events; and pub use events::wait_for_event; to crates/canopy-test-lib/src/lib.rs.
Step 5: E2E test — TSNAP certification
Files: services/canopy-snap/tests/tsnap_e2e_test.rs
This test exercises the full chain: publish a tanf.case_closed event with reason "employment" → canopy-snap subscriber creates a TSNAP certification → verify via GET /v1/tsnap/{household_id}.
// SPDX-License-Identifier: AGPL-3.0-or-later
//! E2E test: TANF closure (employment) -> TSNAP certification created.
use canopy_test_lib::{TestClient, wait_for_event};
async fn setup() -> Option<TestClient> {
if !canopy_test_lib::infrastructure_available().await {
return None;
}
let cfg = canopy_test_lib::TestConfig::from_env();
let c = TestClient::authenticated(&cfg.snap_url).await?;
if !c.is_healthy().await { return None; }
Some(c)
}
#[tokio::test]
async fn tanf_closure_creates_tsnap_certification() {
let Some(c) = setup().await else { return };
// 1. Create a SNAP determination for the household (prerequisite)
let household_id = uuid::Uuid::now_v7();
// ... create SNAP application + determination via POST /v1/determine ...
// 2. Publish tanf.case_closed event (via canopy-tanf or direct MQ publish)
// The subscriber in canopy-snap processes this and creates a TSNAP cert.
// 3. Poll GET /v1/tsnap/{household_id} until the certification appears
let found = wait_for_event(
"TSNAP certification created",
20, // max attempts
500, // interval_ms
|| async {
let resp = c.get(&format!("/v1/tsnap/{household_id}")).await;
resp.status == 200
},
).await;
assert!(found, "TSNAP certification should be created after tanf.case_closed");
// 4. Verify certification details
let resp = c.get(&format!("/v1/tsnap/{household_id}")).await;
resp.assert_status(200);
let cert = resp.json::<serde_json::Value>();
assert!(!cert["reporting_required"].as_bool().unwrap());
assert!(!cert["sanctions_applicable"].as_bool().unwrap());
assert_eq!(cert["status"].as_str().unwrap(), "active");
}
Step 6: E2E test — TMA coverage + GET endpoint
Files: services/canopy-medicaid/tests/tma_e2e_test.rs
Test the chain: publish tanf.case_closed with reason "earned_income" → canopy-medicaid subscriber creates TMA coverage → verify via GET /v1/tma/{household_id} (add this endpoint if not present).
Verify:
-
Coverage period is 12 months
-
QRF schedule has 3 entries at months 4, 7, 10
-
Status is
"active" -
income_limit_pct_fplis 205
Step 7: E2E test — Express Lane
Files: services/canopy-medicaid/tests/express_lane_e2e_test.rs
Test the chain: SNAP approval for a household with children under 19 → snap.application_approved event published → canopy-medicaid Express Lane subscriber evaluates and logs result.
Two sub-cases:
-
Medicaid eligible: household income at 150% FPL with child age 5 →
MedicaidEligible -
PeachCare eligible: household income at 240% FPL with child age 8 →
PeachCareEligible
Use wait_for_event() to poll for the Express Lane evaluation result (verify via logs or, if a store record is created in Step 3, via a GET endpoint).
Step 8: E2E test — negative cases
Files: services/canopy-snap/tests/tsnap_negative_test.rs, services/canopy-medicaid/tests/express_lane_negative_test.rs
Test that non-qualifying events do NOT create records:
-
TSNAP negative:
tanf.case_closedwith reason"voluntary_closure"→ no TSNAP certification created (GET returns 404) -
TMA negative:
tanf.case_closedwith reason"sanction"→ no TMA coverage created -
Express Lane no children: SNAP approval for an adults-only household → Express Lane returns
NoQualifyingChildren -
Express Lane over-income: SNAP approval with income above 247% FPL → Express Lane returns
NotEligible
Files Touched
| File | Change |
|---|---|
|
New: federal cross-program parameters with citations (TSNAP, TMA, Express Lane) |
|
New: DDL for |
|
Add |
|
Add |
|
Add |
|
Complete TSNAP subscriber handler: parse payload, look up SNAP determination, build certification, persist, publish event |
|
Add |
|
Wire |
|
Add |
|
Add |
|
Wire |
|
Add |
|
Complete Express Lane subscriber: parse payload, construct |
|
New: |
|
Add |
|
New: E2E test for TSNAP certification creation from TANF closure |
|
New: E2E test for TMA coverage creation + GET endpoint verification |
|
New: E2E test for Express Lane child Medicaid/PeachCare determination |
|
New: E2E negative tests (voluntary closure does not trigger TSNAP) |
|
New: E2E negative tests (no children, over-income do not trigger Express Lane) |
Verification
-
cargo nextest run --workspace --lib— all unit tests pass (including existing tsnap, tma, express_lane, cross_program tests) -
cargo xtask dev restart— apply the newsnap_tsnap_certificationsmigration -
cargo nextest run --workspace— integration tests pass (including new E2E tests) -
cargo xtask rules check— all JDM rulesets compile -
Verify
rulesets/federal/cross-program-2026.jsonvalues matchcanopy-reference::cross_programconstants (manual check until ADR-011 migration) -
Verify no FTI/PII fields in
snap.application_approvedortanf.application_approvedevent payloads (check viascrub_fti_fields()unit test pattern)
Documentation Updates
-
.claude/docs/services.md— addsnap_tsnap_certificationsto canopy-snap table list; addsnap.application_approvedto canopy-snap event publishing list; addtanf.application_approvedto canopy-tanf event publishing list; documentGET /v1/tsnap/{household_id}endpoint -
CHANGELOG.adoc— entry under== Unreleasedfor TSNAP persistence, Express Lane event publishing, cross-program E2E tests -
docs/modules/ROOT/pages/plans/cross-program-integration.adoc— update status notes to reference this plan for E2E test completion -
.claude/docs/shared-crates.md— documentwait_for_event()in canopy-test-lib section
Errata
Denial reason categorization (2026-04-17)
The original plan assumed canopy-tanf published a reason keyword from
TSNAP_TRIGGER_REASONS / TMA_TRIGGER_REASONS directly on tanf.case_closed.
In practice the TANF JDM ruleset produces human-readable denial strings
(e.g., "Gross income exceeds PAMMS 1501 Gross Income Ceiling"). A categorizer
(categorize_closure_reason() in services/canopy-tanf/src/api/handlers.rs)
maps the full denial string to one of earned_income | time_limit | sanction |
unspecified before publishing. Covered by 4 unit tests.
Payload schema mismatch (2026-04-17)
canopy-tanf publishes termination_date on tanf.case_closed; the canopy-snap
TanfCaseClosedPayload struct declared closure_date. Fixed with
[serde(rename = "termination_date")] in tsnap.rs. tanf_grant_amount is
FTI-scrubbed from the wire payload per ADR-004; made Option<Decimal> with
[serde(default)], treated as Decimal::ZERO in the subscriber.
Postgres max_connections under --shared-db (2026-04-17)
With 17 services sharing one Postgres instance plus integration test load, the
default max_connections = 100 is exhausted. Raised to 400 via command:
["postgres", "-c", "max_connections=400"] in docker-compose.yml.
ELE persistence layer (Step 8, 2026-04-17)
The original plan’s Step 8 spec’d "negative/boundary tests" as the final step
without requiring a persistence layer for ELE — the subscriber was to log
results and actual enrollment was deferred to orchestrator referral (ADR-005).
In practice, observing the subscriber’s decisions required something to
persist. Added express_lane_evaluations table + record_express_lane_
evaluation() store function + GET /v1/express-lane handler so both
workers and tests can see ELE results without waiting on enrollment.
When canopy-persons is unreachable or returns 401 (no service-to-service
JWT in the subscriber yet), the subscriber records no_qualifying_children
instead of silently returning. This keeps the ELE decision auditable. A
follow-up to add a machine-to-machine token (Keycloak client credentials)
so the subscriber can authenticate to canopy-persons is tracked below.
Follow-up: service-to-service auth for ELE subscriber
The ELE subscriber calls canopy-persons to fetch household members but
has no JWT — it receives 401 today. A machine-to-machine OAuth client
(Keycloak client credentials grant) or a shared service API key would
allow the subscriber to authenticate without a worker session. Until
that’s in place, the subscriber records no_qualifying_children when
persons is unreachable.
ADR-011 compliance follow-up
The constants in crates/canopy-reference/src/cross_program.rs (TSNAP_CERTIFICATION_MONTHS = 5, TMA_COVERAGE_MONTHS = 12, TMA_QRF_DUE_MONTHS = [4, 7, 10], EXPRESS_LANE_MEDICAID_FPL_PCT = 235, EXPRESS_LANE_PEACHCARE_FPL_PCT = 247, EXPRESS_LANE_MAX_AGE = 19) must be migrated to load from rulesets/federal/cross-program-2026.json at service startup. This follows the pattern established by SnapParameterTable::load() and MedicaidParameterTable::load(). Until this migration is complete, the JSON file is the authoritative source and the Rust constants must be kept in sync manually. This follow-up is tracked as a separate ADR-011 compliance task and is not part of this plan.