Plan: Cross-Program Functional Testing

On this page

Status

Step Description Status

0

Federal parameter file: rulesets/federal/cross-program-2026.json with citations

Done — rulesets/federal/cross-program-2026.json exists with TSNAP / TMA / Express Lane / TCOS sections; all values cited in rulesets/federal/citations.toml (cross-program-2026.tsnap., cross-program-2026.tma., cross-program-2026.express_lane.*); cargo xtask policy audit clean.

1

TSNAP subscriber completion: migration, store, handler, GET endpoint

Done — Migration services/canopy-snap/migrations/20260414000000_create_tsnap_certifications.sql (single tanf_closure_reason column instead of plan’s separate status); store layer services/canopy-snap/src/store/tsnap.rs (TsnapCertificationRow, create_tsnap_certification, get_tsnap_certification, list_tsnap_certifications); subscriber wired in services/canopy-snap/src/main.rs:115-194 (canopy-snap.tsnap queue, tanf.case_closed routing, eligibility check via is_tsnap_eligible, SNAP allotment lookup, certification persistence); GET /v1/tsnap/{id} + GET /v1/tsnap?household_id=… in src/api/tsnap_handler.rs. Note: snap.tsnap_created event publication after persistence is not wired — internal subscriber-driven workflow doesn’t need a downstream listener today; if one becomes needed it’s a one-line events::publish_* call.

2

Express Lane event publishing from canopy-snap and canopy-tanf (snap.application_approved, tanf.application_approved)

Done — events::publish_application_approved in both services/canopy-snap/src/events.rs (called from api/determine_handler.rs:120) and services/canopy-tanf/src/events.rs (called from api/handlers.rs:96). Design evolved away from the plan’s payload-carries-children-ages-and-fpl-100 shape: events carry {household_id, application_id|determination_id, program} and the canopy-medicaid subscriber fetches household composition + children’s ages + monthly income from canopy-persons via HTTP per ADR-001. This is strictly better — the source of truth for household data stays in canopy-persons; events stay PII-free.

2b

FPL accessor for SNAP and TANF param tables

Done (N/A — design deviation) — Plan called for fpl_100_monthly() accessors on SnapParameterTable and TanfParameterTable so events could carry fpl_100_monthly to the subscriber. Per the Step 2 deviation (subscriber fetches its own data from canopy-persons + uses MedicaidParameterTable::fpl_100_monthly which already exists), the SNAP/TANF accessors are not needed. Skipped to avoid dead code.

3

Express Lane subscriber completion in canopy-medicaid

Done — services/canopy-medicaid/src/main.rs:200-340 subscribes to canopy-medicaid.express-lane queue with both snap.application_approved and tanf.application_approved routing; fetches /v1/households/{id}/members from canopy-persons; computes children’s ages from DOB; sums monthly income; constructs ExpressLaneContext; calls check_express_lane; persists evaluation via store::record_express_lane_evaluation (not just logged — the plan said persistence was optional, but it shipped). Decision results: medicaid_eligible / peachcare_eligible / not_eligible / no_qualifying_children.

4

wait_for_event() helper in canopy-test-lib

Done (2026-04-27) — Added to crates/canopy-test-lib/src/poll.rs alongside the pre-existing poll_until (which has a similar but more general Option<T>-returning shape). New wait_for_event(description, max_attempts, interval_ms, check) → bool matches the plan’s signature exactly. Re-exported from lib.rs.

5

E2E test: TSNAP certification created from TANF closure

Done — services/canopy-snap/tests/tsnap_e2e_test.rs tanf_employment_denial_creates_tsnap_certification (employment closure → TSNAP cert created, polled via the existing poll_until helper).

6

E2E test: TMA coverage created + GET endpoint verification

Done — services/canopy-medicaid/tests/tma_e2e_test.rs tanf_earned_income_denial_creates_tma_coverage + tanf_multi_member_au_creates_one_coverage_per_person (closes the multi-member case the plan didn’t enumerate).

7

E2E test: Express Lane child Medicaid/PeachCare determination

Done — services/canopy-medicaid/tests/express_lane_e2e_test.rs tanf_approval_triggers_express_lane_evaluation (TANF approval → ELE record exists for the household).

8

E2E test: negative cases (voluntary closure, no children, over-income)

Done — tsnap_e2e_test.rs::tanf_non_employment_denial_does_not_create_tsnap, tma_e2e_test.rs::tanf_non_tma_denial_does_not_create_coverage, express_lane_e2e_test.rs::tanf_approval_without_children_records_no_qualifying_children. All three negative paths covered. All 9 e2e tests green against devstack as of 2026-04-27.

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.json with full citation metadata

  • TSNAP: database migration (snap_tsnap_certifications table), store CRUD, subscriber completion in canopy-snap/src/main.rs, GET /v1/tsnap/{household_id} endpoint

  • Express Lane event publishing: snap.application_approved from canopy-snap, tanf.application_approved from canopy-tanf

  • FPL accessor: fpl_100_monthly() method on SnapParameterTable and TanfParameterTable (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_program constants 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"
  }
}
NOTE
The constants in 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:

  1. Parse household_id, closure_date, tanf_grant_amount from the tanf.case_closed event payload (use the existing TanfCaseClosedPayload struct).

  2. Look up the current SNAP determination for the household to get pre_closure_snap_allotment (query snap_determinations by household_id with status = 'approved' ordered by determined_at DESC).

  3. Call build_tsnap_certification() with the parsed values.

  4. Call store::create_tsnap_certification() to persist.

  5. Publish snap.tsnap_created event (using the existing TSNAP_CREATED constant).

1e: GET endpoint

Add GET /v1/tsnap/{household_id} to services/canopy-snap/src/api.rs. Returns 200 with the TsnapCertificationRow JSON if an active TSNAP certification exists, or 404 if not found. Follow the existing handler pattern in the file.

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.

2b: TANF application_approved event

Add a matching publish_application_approved to services/canopy-tanf/src/events.rs with source_program: "tanf". Wire it into the TANF determination handler after successful approval.

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:

  1. Parse the event payload fields: household_id, verified_monthly_income, household_size, children_ages, fpl_100_monthly, source_program.

  2. 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,
};
  1. Call express_lane::check_express_lane(&ctx).

  2. If the result is MedicaidEligible or PeachCareEligible, 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.

  3. If NoQualifyingChildren or NotEligible, 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_fpl is 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:

  1. Medicaid eligible: household income at 150% FPL with child age 5 → MedicaidEligible

  2. 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:

  1. TSNAP negative: tanf.case_closed with reason "voluntary_closure" → no TSNAP certification created (GET returns 404)

  2. TMA negative: tanf.case_closed with reason "sanction" → no TMA coverage created

  3. Express Lane no children: SNAP approval for an adults-only household → Express Lane returns NoQualifyingChildren

  4. Express Lane over-income: SNAP approval with income above 247% FPL → Express Lane returns NotEligible

Files Touched

File Change

rulesets/federal/cross-program-2026.json

New: federal cross-program parameters with citations (TSNAP, TMA, Express Lane)

services/canopy-snap/migrations/20260415000000_create_snap_tsnap_certifications.sql

New: DDL for snap_tsnap_certifications table with indexes

services/canopy-snap/src/store/models.rs

Add TsnapCertificationRow struct

services/canopy-snap/src/store/mod.rs

Add create_tsnap_certification() and find_active_tsnap() store functions

services/canopy-snap/src/api.rs

Add GET /v1/tsnap/{household_id} handler

services/canopy-snap/src/main.rs

Complete TSNAP subscriber handler: parse payload, look up SNAP determination, build certification, persist, publish event

services/canopy-snap/src/events.rs

Add publish_application_approved() for Express Lane event

services/canopy-snap/src/determine.rs

Wire publish_application_approved() call after successful SNAP approval

services/canopy-snap/src/params.rs

Add fpl_100_monthly() accessor method to SnapParameterTable

services/canopy-tanf/src/events.rs

Add publish_application_approved() for Express Lane event with source_program: "tanf"

services/canopy-tanf/src/determine.rs

Wire publish_application_approved() call after successful TANF approval

services/canopy-tanf/src/params.rs

Add fpl_100_monthly() accessor (load from fpl-2026.json if not already present)

services/canopy-medicaid/src/main.rs

Complete Express Lane subscriber: parse payload, construct ExpressLaneContext, call check_express_lane()

crates/canopy-test-lib/src/events.rs

New: wait_for_event() async polling helper

crates/canopy-test-lib/src/lib.rs

Add pub mod events and re-export wait_for_event

services/canopy-snap/tests/tsnap_e2e_test.rs

New: E2E test for TSNAP certification creation from TANF closure

services/canopy-medicaid/tests/tma_e2e_test.rs

New: E2E test for TMA coverage creation + GET endpoint verification

services/canopy-medicaid/tests/express_lane_e2e_test.rs

New: E2E test for Express Lane child Medicaid/PeachCare determination

services/canopy-snap/tests/tsnap_negative_test.rs

New: E2E negative tests (voluntary closure does not trigger TSNAP)

services/canopy-medicaid/tests/express_lane_negative_test.rs

New: E2E negative tests (no children, over-income do not trigger Express Lane)

Verification

  1. cargo nextest run --workspace --lib — all unit tests pass (including existing tsnap, tma, express_lane, cross_program tests)

  2. cargo xtask dev restart — apply the new snap_tsnap_certifications migration

  3. cargo nextest run --workspace — integration tests pass (including new E2E tests)

  4. cargo xtask rules check — all JDM rulesets compile

  5. Verify rulesets/federal/cross-program-2026.json values match canopy-reference::cross_program constants (manual check until ADR-011 migration)

  6. Verify no FTI/PII fields in snap.application_approved or tanf.application_approved event payloads (check via scrub_fti_fields() unit test pattern)

Documentation Updates

  • .claude/docs/services.md — add snap_tsnap_certifications to canopy-snap table list; add snap.application_approved to canopy-snap event publishing list; add tanf.application_approved to canopy-tanf event publishing list; document GET /v1/tsnap/{household_id} endpoint

  • CHANGELOG.adoc — entry under == Unreleased for 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 — document wait_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.

Edit this page · default