Plan: ELE 1-year-flag expansion (SNAP+TANF demo video)
On this page
- Status
- Context
- Locked decisions
- Architecture
- Schema (canopy-medicaid migrations, forward-only per ADR-016)
- Hash chain (parallel to FTI per ADR-014)
- Outbox events (PII-allowlist)
- JDM rulesets (
rulesets/federal/, ADR-003 compliance) - Cross-program parameter extension
- Subscriber wiring (canopy-medicaid/src/main.rs)
- Renewal scheduler (canopy-medicaid/src/scheduler.rs — NEW)
- Demo script (Plan 2 partial-demo path)
- Verification
- Risks & mitigations
- References
- Out of scope (handled by other plans or post-Plan-2)
Status
| MR | Description | Status |
|---|---|---|
1 |
|
Done (2026-05-29) — #642. Added |
2 |
|
Done (2026-05-29) — #643. |
3 |
|
Done (2026-05-29) — #644. |
4 |
|
Done (2026-05-29) — 645. Three federal rulesets ( |
5 |
|
Done (2026-05-29) — #646. Scope expanded per the user directive "rip out ELE logic hardcoded in Rust source — it can vary by state, so it belongs in the rules engine." On investigation a pre-Plan-2 express-lane feature already existed (commit |
6 |
|
Done (2026-05-29) — #647. ELE source-closure LAPSE handling is folded INTO the existing |
7 |
|
Done (2026-05-29) — #648. PLAN 2 COMPLETE. New |
Sister plans: Plan 1 (worker intake + program independence — DONE 2026-05-27 MR !387) and Plan 3 (applicant intake + verification — forthcoming combined commit).
Meta-plan handoff: ~/.claude/projects/-home-bitskrieg-code-canopy/memory/project_demo_video_3plan_handoff.md.
Branch: feat/ele-1-year-flag-extension (epic) with per-MR feature branches.
Labels: priority::high, program::medicaid, program::snap, program::tanf, service::medicaid, service::applications, service::web, service::rules, service::shared-crates, service::devstack, type::feature, workflow::ready.
Context
The demo-video epic requires Express Lane Eligibility (ELE) to auto-grant a 1-year Medicaid/PeachCare flag for children when a household’s SNAP or TANF application approves, then extend (not reset) the flag if another source program approves later, then renew or lapse at the 1-year mark.
canopy-medicaid today has the evaluation primitive (services/canopy-medicaid/src/express_lane.rs::check_express_lane) and an evaluation log (express_lane_evaluations table from migration 20260417000000), but no durable 1-year flag, no consent record, no renewal scheduler, no lapse triggers, and no UI surface on case detail. The existing subscriber at services/canopy-medicaid/src/main.rs:264-392 records evaluations but doesn’t grant durable status.
Plan 2 fills these gaps. It is structurally independent of Plans 1 and 3 — the existing subscriber and parameter machinery are already in place, and Plan 2 extends both. Plan 2’s seed-data dependencies are minimal: the partial-demo path needs ELE-consent records seeded for the Plan-1-MR6 multi-program personas; this is one additional row per persona in the canopy-seed demo profile.
Locked decisions
| Decision | Choice |
|---|---|
ELE consent model |
Separate explicit consent event from canopy-applications via a NEW dedicated endpoint. DO NOT extend |
Clock start (ADR-003 compliance) |
JDM picks among Rust-precomputed date candidates. Per the existing TMA ruleset convention ( |
Lapse triggers (all four) |
(a) Child ages out — caught on daily cron tick. (b) TANF case closure — subscribe to existing (c) Worker manual revoke — new admin endpoint (d) Income exceeds 247% FPL — NO All lapse decisions route through |
Renewal model (ADR-003 compliance) |
At 1-year mark, scheduler ticks via |
Storage model |
Three new tables in canopy-medicaid: |
Hash-chain (parallel to FTI per ADR-014) |
New |
Cross-program parameter |
New field |
Identity hero badge |
|
Plan 2 ↔ Plan 3 dependency |
Plan 2 MR3 ships the NEW dedicated endpoint |
Architecture
┌──────────────── canopy-applications ─────────────────────┐
│ POST /v1/applications/{id}/ele-consent (NEW endpoint) │
│ └─ Body: {consent_source, language, notes?} │
│ └─ Validates application exists + in submitted/data_ │
│ collected/processing status │
│ └─ Emits "application.ele_consent_recorded" event via │
│ outbox in same tx as the validation │
│ └─ NO applications-table column added; NO │
│ CreateApplicationRequest extension │
│ └─ RBAC: require_service_or_applicant_or_caseworker_or_ │
│ above (NEW guard declared in MR3) │
└───────────────────────────────────────────────────────────┘
│ (RabbitMQ topic canopy.events)
▼
┌──────────────── canopy-medicaid ─────────────────────────┐
│ Subscriber: application.ele_consent_recorded │
│ └─ INSERT ele_consents (UNIQUE on (hh,person) ACTIVE) │
│ │
│ Subscriber: snap.application_approved + │
│ tanf.application_approved (EXISTING) │
│ └─ Check ele_consents row exists → if no, log + skip │
│ └─ Fetch household members + per-person DOB + HoH income │
│ via canopy-persons HTTP with service-token auth │
│ └─ Rust precomputes granted_at/expires_at candidates │
│ └─ POST canopy-rules /v1/evaluate (ele-grant-2026) │
│ └─ JDM decision: grant | extend | already_active | skip │
│ └─ Atomic tx: │
│ - express_lane_evaluations row (EXISTING, log only) │
│ - ele_grant_events row (NEW, hash-chained) │
│ - ele_status row (NEW, upsert; appends to │
│ granting_program_history TEXT[]) │
│ - outbox emit ele.granted | ele.extended │
│ │
│ Subscriber: tanf.case_closed (VERIFIED extant) │
│ └─ Remove 'tanf' from granting_program_history; if │
│ history empty → ele-lapse-2026.json → atomic tx │
│ │
│ Endpoint: POST /v1/ele/{person_id}/revoke (NEW admin) │
│ Endpoint: GET /v1/ele/{person_id} (NEW) │
│ Endpoint: GET /v1/ele/household/{household_id} (NEW) │
│ └─ Returns EleHouseholdSummary; powers identity hero │
│ Endpoint: GET /v1/ele/chain-status (NEW) │
│ │
│ Scheduler: canopy-medicaid/src/scheduler.rs (NEW) │
│ └─ run_with_advisory_lock("canopy-medicaid.ele-renewal") │
│ └─ Daily tick — two queries: (1) renewal-due via JDM, │
│ (2) age-out (pure-age, local SQL). │
│ └─ Returns SchedulerOutcome<EleSchedulerCheckResult> │
│ preserving Ran(T) | Skipped (losing-replica case) │
└───────────────────────────────────────────────────────────┘
│
▼
┌──────────────── canopy-web ──────────────────────────────┐
│ build_identity_hero() at case_detail.rs:914-1000 │
│ + new GET clients.medicaid.get( │
│ "/v1/ele/household/{household_id}") │
│ + CaseIdentityHero.ele_household_summary: │
│ Option<EleHouseholdSummary> │
│ templates/case_detail/_identity_hero.html │
│ + meta-row badge "ELE active for {n} children until {d}" │
└───────────────────────────────────────────────────────────┘
Schema (canopy-medicaid migrations, forward-only per ADR-016)
20260605000000_create_ele_consents.sql
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Plan 2 MR2 — durable applicant ELE consent record.
CREATE TABLE ele_consents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_id UUID NOT NULL,
person_id UUID NOT NULL,
application_id UUID NOT NULL,
consent_given_at TIMESTAMPTZ NOT NULL,
consent_recorded_by UUID NOT NULL,
consent_source TEXT NOT NULL
CHECK (consent_source IN ('applicant_portal', 'worker_attestation')),
language TEXT NOT NULL DEFAULT 'en',
revoked_at TIMESTAMPTZ,
revoked_by UUID,
revoke_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Stable predicate (NULL-check is time-invariant).
CREATE UNIQUE INDEX ele_consents_active_per_person
ON ele_consents (household_id, person_id)
WHERE revoked_at IS NULL;
CREATE INDEX ele_consents_household_idx ON ele_consents (household_id);
CREATE INDEX ele_consents_application_idx ON ele_consents (application_id);
20260605000001_create_ele_status.sql
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Plan 2 MR2 — current ELE snapshot per child.
CREATE TABLE ele_status (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
child_person_id UUID NOT NULL,
household_id UUID NOT NULL,
child_date_of_birth DATE NOT NULL, -- DENORMALIZED from canopy-persons
-- for local age-out query (ADR-001).
eligibility_tier TEXT NOT NULL
CHECK (eligibility_tier IN ('medicaid', 'peachcare')),
granted_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
granting_program_history TEXT[] NOT NULL DEFAULT '{}',
current_status TEXT NOT NULL DEFAULT 'active'
CHECK (current_status IN ('active', 'lapsed', 'revoked', 'pending_redetermination')),
last_event_id UUID NOT NULL,
last_event_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ele_status_active_per_child
ON ele_status (child_person_id) WHERE current_status = 'active';
CREATE INDEX ele_status_household_idx ON ele_status (household_id);
CREATE INDEX ele_status_renewal_due_idx
ON ele_status (expires_at) WHERE current_status = 'active';
20260605000002_create_ele_grant_events.sql
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Plan 2 MR2 — append-only event log with hash-chain integrity per
-- ADR-014 parallel to FTI audit pattern. Source-of-truth.
CREATE TABLE ele_grant_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
child_person_id UUID NOT NULL,
household_id UUID NOT NULL,
event_type TEXT NOT NULL
CHECK (event_type IN ('granted', 'extended', 'renewed', 'lapsed', 'revoked')),
source_program TEXT
CHECK (source_program IS NULL OR source_program IN ('snap','tanf','caps','wic','none')),
-- NULLABLE: SNAP carries determination_id; TANF only application_id.
source_determination_id UUID,
source_application_id UUID,
eligibility_tier TEXT
CHECK (eligibility_tier IS NULL OR eligibility_tier IN ('medicaid','peachcare')),
granted_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
reason_code TEXT NOT NULL,
actor_id UUID,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
previous_hash TEXT,
event_hash TEXT NOT NULL UNIQUE,
-- Holds: jdm_decision_reason, jdm_ruleset_version, optional trace_id,
-- forward-compat extension fields. NEVER income amounts, SSN, DOB.
-- Both chain-hash compute AND outbox emit canonicalize identically.
payload JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX ele_grant_events_child_idx
ON ele_grant_events (child_person_id, occurred_at DESC);
CREATE INDEX ele_grant_events_household_idx
ON ele_grant_events (household_id, occurred_at DESC);
CREATE INDEX ele_grant_events_chain_idx
ON ele_grant_events (previous_hash) WHERE previous_hash IS NOT NULL;
ADR-016 risk: all three new tables, no schema mutation on existing express_lane_evaluations. Forward-only.
MR2 implementation refinements (shipped): (1) the ele_grant_events chain index is (occurred_at, id) rather than the sketched (previous_hash) — that composite supports the predecessor lookup (ORDER BY occurred_at DESC, id DESC LIMIT 1) and the chain-verify walk; id (uuid v7, minted in lock order) is a stable same-microsecond tiebreaker. (2) occurred_at keeps its DEFAULT clock_timestamp() but the store binds it explicitly to Utc::now() sampled after the advisory lock is held, so the hashed value equals the stored value and is monotonic under the lock — this is the Bug-7 fix without needing a separate clock-ordering column.
Hash chain (parallel to FTI per ADR-014)
New file crates/canopy-common/src/ele_audit.rs mirrors fti_audit.rs shape:
pub fn compute_ele_event_hash(
previous_hash: Option<&str>,
id: Uuid,
child_person_id: Uuid,
household_id: Uuid,
event_type: &str,
source_program: Option<&str>,
source_determination_id: Option<Uuid>,
eligibility_tier: Option<&str>,
granted_at: Option<&DateTime<Utc>>,
expires_at: Option<&DateTime<Utc>>,
reason_code: &str,
actor_id: Option<Uuid>,
occurred_at: &DateTime<Utc>,
payload_canonical_json: &str,
) -> String // SHA-256 hex
The FTI parallel at crates/canopy-common/src/fti_audit.rs:76-125 does NOT have a payload column today (FTI rows are flat); ELE’s design intentionally extends the pattern with payload for forward-compat. Canonicalization rule: serde_json::to_string after sorting keys, no whitespace. Both consumers (compute + verify) must canonicalize identically.
Same canonical timestamp format as fti_audit.rs:105-106 (%Y-%m-%dT%H:%M:%S%.6f+00:00). Lock acquisition follows fti_audit.rs:317-320 pattern using a distinct lock name "canopy-medicaid.ele_chain" (derived via SHA-256-first-8-bytes-as-i64 per crates/canopy-db/src/advisory.rs:46-52). Distinct lock space from FTI.
Chain breach detection: new GET /v1/ele/chain-status endpoint mirroring GET /v1/security/verify-chain (paths.rs:24). Returns { valid: bool, events_verified: u64, broken_at: Option<Uuid>, message: Option<String> }.
Outbox events (PII-allowlist)
Per ADR-014 + project convention, all five event payloads use allowlist only:
EleGrantedEvent {
child_person_id: PersonId,
household_id: HouseholdId,
source_program: Program,
source_determination_id: Option<DeterminationId>,
source_application_id: Option<ApplicationId>,
eligibility_tier: EligibilityTier,
granted_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
occurred_at: DateTime<Utc>,
}
EleExtendedEvent { /* same shape; source_program is NEW program added */ }
EleRenewedEvent { /* child_person_id, household_id, granted_at (new), expires_at (new), occurred_at */ }
EleLapsedEvent { /* child_person_id, household_id, reason_code, occurred_at */ }
EleRevokedEvent { /* child_person_id, household_id, actor_id, reason_code, occurred_at */ }
NO income amounts, NO SSN, NO DOB. All routing keys prefixed medicaid.ele.*.
JDM rulesets (rulesets/federal/, ADR-003 compliance)
Three new rulesets. All inputs passed in the JDM payload (JDM cannot call Rust).
rulesets/federal/ele-grant-2026.json (as built, MR4)
The JDM input is intentionally narrow — only the fields a decision-table cell can test. The date candidates AND the income percentage are precomputed by the MR5 Rust subscriber (the * 100 percentage conversion cannot live in JDM: rules lint-inputs rejects bare numeric literals per ADR-011, exactly as the date math is barred by medicaid-tma-phase.json:5). The full nested existing_ele_status object collapses to a single presence bit because that is all grant-vs-extend needs.
// input.* (computed in Rust / MR5)
{
"child_eligibility_status": "eligible",
"child_age": 7,
"income_pct_fpl": 114,
"has_existing_active_ele": false
}
// context.thresholds.* (from cross-program-2026.json express_lane, populated by the caller)
{ "medicaid_fpl_pct": 235, "peachcare_fpl_pct": 247, "max_age": 19, "grant_months": 12 }
Outputs:
{
"decision": "grant", // grant | extend | skip
"eligibility_tier": "medicaid", // medicaid | peachcare | none
"use_granted_at_candidate": true, // grant → true; extend keeps prior expiry → false
"use_expires_at_candidate": true,
"decision_reason": "grant_medicaid"
}
Rust subscriber (MR5) precomputes the candidates the ruleset echoes back (per the medicaid-tma-phase.json:5 precedent — JDM expresses neither month-granular date math nor the percentage division):
let granted_at_candidate = source_approval_at.max(
child_first_qualifying_date_utc.unwrap_or(source_approval_at)
);
let expires_at_candidate = ele_grant::grant_expiry(granted_at_candidate, grant_months);
let income_pct_fpl = verified_monthly_income_cents * 100 / fpl_100_monthly_cents;
The decision table (hitPolicy "first", 8 rules) gates on eligible → age ceiling → over-income → then tier (medicaid ≤235, peachcare ≤247) × grant-vs-extend (has_existing_active_ele), with a defensive skip/none/no_match catch-all. On extend, use_*_candidate are both false so the caller keeps the existing expiry (a second source program extends coverage, it does not reset the year).
RESOLVED (MR5): calendar months, not a 30-day approximation. The grant window is a true continuous-eligibility year (42 CFR 435.1102), so expiry uses chrono::Months calendar arithmetic via the testable ele_grant::grant_expiry(granted_at, grant_months) (handles leap years + end-of-month clamping; unit-tested to be 365/366 days, explicitly not 360). The earlier Duration::days(30 * grant_months) placeholder undercounted a 12-month grant by ~5 days and was corrected before MR5 merged.
rulesets/federal/ele-renewal-2026.json (as built, MR4)
Inputs input.{child_eligibility_status, child_age, income_verified, income_pct_fpl} + context.thresholds.{medicaid_fpl_pct, peachcare_fpl_pct, max_age}. Output { decision: "extend"|"lapse"|"pending_redetermination", eligibility_tier, use_new_dates, decision_reason }. 7-rule first-match table: not-eligible → lapse; aged-out → lapse; income unverified → pending_redetermination (route to a worker, never silent-lapse); over-income → lapse; else renew at medicaid/peachcare tier with use_new_dates=true (the scheduler stamps a fresh granted_at/expires_at — renewal resets the clock, unlike grant-time extend). Does not require the source program to still be active (ELE is durable). Catch-all → lapse.
rulesets/federal/ele-lapse-2026.json (as built, MR4)
For the mid-period change subscribers (MR6: source-close + income-change). Inputs input.{trigger_event, has_other_active_source}; output { decision: "lapse"|"keep", reason_code }. Durability-biased per 42 CFR 435.1102 continuous eligibility: a flag is durable through its certification period, so source_closed (even with no other active source) and income_change both resolve to keep — the ruleset lapses only when the eligibility basis is lost entirely (aged_out_exceeded / moved_out_of_jurisdiction / deceased). The latter three are forward-looking (not wired until a later MR); the ruleset is the policy, broader than the current subscriber set. Catch-all → keep.
Cross-program parameter extension
crates/canopy-reference/src/cross_program.rs:114-123:
pub struct ExpressLaneParams {
pub medicaid_fpl_pct: u32, // existing
pub peachcare_fpl_pct: u32, // existing
pub max_age: u32, // existing
#[serde(default = "default_grant_months")]
pub grant_months: u32, // NEW
}
fn default_grant_months() -> u32 { 12 }
rulesets/federal/cross-program-2026.json:
"express_lane": {
"medicaid_fpl_pct": 235,
"peachcare_fpl_pct": 247,
"max_age": 19,
"grant_months": 12
}
ADR-011 citation: new entry at rulesets/federal/citations.toml (NOT georgia — existing express_lane citations are federal-scoped at lines 188-217). Key [citations."cross-program-2026.express_lane.grant_months"] matches the existing keying convention. PAMMS anchor (dfcs-medicaid §2069 + 42 CFR 435.1102 — SME confirms exact section at MR4).
Subscriber wiring (canopy-medicaid/src/main.rs)
The existing services/canopy-medicaid/src/main.rs:141 .layer(axum::Extension(boot.publisher)) MOVES boot.publisher out of scope BEFORE the subscriber block at line 264. Plan 2 MR5 captures clones BEFORE that line:
// At the TOP of the layer-building section (BEFORE line 141), add:
let ele_publisher = boot.publisher.clone();
let ele_rules_client = rules_client.clone();
let ele_service_token = boot.service_token_source.clone()
.ok_or_else(|| anyhow::anyhow!("canopy-medicaid requires OIDC service token for ELE outbound"))?;
// ... existing .layer(axum::Extension(boot.publisher)) at line 141 unchanged ...
Then the subscriber (extending existing block at main.rs:264-392):
boot.subscriber.subscribe(
"canopy-medicaid.express-lane",
&["snap.application_approved", "tanf.application_approved"],
ele_inbox_pool,
5,
move |envelope, tx| {
let publisher = ele_publisher.clone();
let rules_client = ele_rules_client.clone();
let service_token = ele_service_token.clone();
let xparams = ele_xparams.clone();
Box::pin(async move {
// ... existing extraction of household_id, source_program ...
// NEW: check ele_consents
let consent_present = store::ele::has_active_consent(&mut *tx, household_id).await?;
if !consent_present {
tracing::info!(household_id = %household_id, "ELE skipped: no consent on file");
return Ok(());
}
// canopy-persons fan-out. canopy-medicaid does NOT have a
// ServiceClients struct (that lives in canopy-web only at
// services/canopy-web/src/clients.rs:246); the existing subscriber
// at main.rs:260 uses raw reqwest::Client and that pattern
// continues here. Auth is service-token via Authorization: Bearer
// header per ADR-019.
//
// Fetch chain:
// 1. GET /v1/households/{id} → HouseholdWithMembers (members
// have person_id + relationship + effective_date but NOT DOB
// per crates/canopy-contracts-persons/src/households.rs:33-43).
// 2. For each member: GET /v1/persons/{person_id} → Person
// (carries date_of_birth per persons.rs:36).
// 3. For HoH: GET /v1/persons/{hoh_id}/income → Vec<Income>
// (LIST_INCOME path at paths.rs:23). No household-aggregate
// income endpoint exists; per-person fan-out is canonical.
let token = service_token.access_token().await?;
let hh_resp = http
.get(&format!("{}/v1/households/{}", persons_url, household_id))
.bearer_auth(&token)
.send().await?.error_for_status()?;
let household: HouseholdWithMembers = hh_resp.json().await?;
let mut children_under_max_age = Vec::new();
for member in &household.members {
let person_resp = http
.get(&format!("{}/v1/persons/{}", persons_url, member.person_id))
.bearer_auth(&token)
.send().await?.error_for_status()?;
let person: Person = person_resp.json().await?;
let age = chrono::Utc::now().date_naive()
.years_since(person.date_of_birth).unwrap_or(0);
if age < params.express_lane.max_age {
children_under_max_age.push((member.clone(), person));
}
}
let hoh_person_id = household.members.first()
.map(|m| m.person_id.to_string())
.ok_or_else(|| anyhow::anyhow!("household has no members"))?;
let income_resp = http
.get(&format!("{}/v1/persons/{}/income", persons_url, hoh_person_id))
.bearer_auth(&token)
.send().await?.error_for_status()?;
let income_list: Vec<Income> = income_resp.json().await?;
let verified_monthly_income_cents = compute_verified_monthly(&income_list);
for (member, person) in children_under_max_age {
let existing = store::ele::current_ele_status(&mut *tx, member.person_id).await?;
let inputs = build_jdm_inputs(/* ... */);
let decision: GrantDecision = rules_client
.evaluate_ele_grant(inputs, Some(token.as_str()))
.await?;
match decision.decision.as_str() {
"grant" | "extend" => {
let new_event = compute_ele_event_hash(/* … */);
store::ele::insert_ele_grant_event(&mut *tx, &new_event).await?;
store::ele::upsert_ele_status(&mut *tx, &decision, &new_event).await?;
events::publish_ele_granted(&mut *tx, &publisher, /* … */).await?;
}
"already_active" | "skip" => { /* no-op */ }
_ => tracing::warn!(decision = %decision.decision, "unknown ELE decision"),
}
}
Ok(())
})
},
);
ELE source-closure handling added in MR6 (the only closure event with a verified-extant publisher is tanf.case_closed):
-
tanf.case_closed— handled in canopy-medicaid byhandle_ele_case_closed, folded INTO the existingcanopy-medicaid.tmasubscriber’s handler (same group, same transaction), NOT a separate subscriber group. The per-serviceevent_inboxdedups onevent_idalone, so a second group bound to the same routing key would lose the inbox-INSERT race and silently never run — TMA + ELE must share one handler. As built, it routes the decision throughele-lapse-2026.jsonfor every matching flag (not gated on "history empties" in Rust — the decision lives in JDM per ADR-003), passinghas_other_active_sourcederived from the post-removal projection. The durability-biased ruleset returnskeep, so the flag stays active and onlygranting_program_historydrops TANF (set_ele_granting_history); thelapsebranch (transition_ele_status→lapsedevent → emit) is wired but unreachable under the federal ruleset.
Plan 2 files two follow-up GitLab issues (post-Plan-2 enhancements):
-
canopy-snap
snap.case_closedpublisher (current gap) — filed as #651. -
canopy-persons
persons.income_changedpublisher (current gap) — filed as #652.
Renewal scheduler (canopy-medicaid/src/scheduler.rs — NEW)
Mirrors services/canopy-renewals/src/scheduler.rs:89 pattern. Return type is Result<SchedulerOutcome<EleSchedulerCheckResult>> per crates/canopy-db/src/advisory.rs:82-90 — SchedulerOutcome variants are Ran(T) and Skipped. canopy-renewals' SchedulerCheckResult at scheduler.rs:19-22 has fields { renewals_due, interim_contacts_due } — those don’t fit ELE semantics, AND importing across program-service boundaries violates ADR-001. Plan 2 defines a LOCAL struct:
/// ELE renewal-scheduler tick outcome. Local to canopy-medicaid per
/// ADR-001 program-service isolation (NOT imported from canopy-renewals).
#[derive(Debug, Clone, Copy)]
pub struct EleSchedulerCheckResult {
pub processed: u32,
pub errors: u32,
}
No canopy-renewals dependency in services/canopy-medicaid/Cargo.toml.
pub async fn run_ele_renewal_tick(
db: PgPool,
rules_client: MedicaidRulesClient,
service_token: ServiceTokenSource,
publisher: Publisher,
xparams: Arc<CrossProgramParameterTable>,
) -> anyhow::Result<SchedulerOutcome<EleSchedulerCheckResult>> {
run_with_advisory_lock(&db, "canopy-medicaid.ele-renewal", || async {
let mut processed: u32 = 0;
let mut errors: u32 = 0;
// (1) Renewal-due: re-evaluate against current state via JDM.
let near_expiry = store::ele::find_ele_status_near_expiry(&db).await?;
for row in &near_expiry {
let token = service_token.access_token().await?;
let inputs = build_renewal_inputs(row, &xparams.express_lane);
let decision = rules_client.evaluate_ele_renewal(inputs, Some(token.as_str())).await;
match apply_renewal_decision(&db, &publisher, row, decision).await {
Ok(()) => processed += 1,
Err(e) => { tracing::warn!(error = %e, "ELE renewal apply failed"); errors += 1; }
}
}
// (2) Age-out: pure-age check, local to canopy-medicaid
// (child_date_of_birth denormalized into ele_status per §4).
let aged_out = store::ele::find_aged_out_children(&db, xparams.express_lane.max_age).await?;
for row in &aged_out {
apply_age_out_lapse(&db, &publisher, row).await?;
processed += 1;
}
// (3) [Dropped]. A "7-day stale source-state re-evaluation backstop"
// cannot work — canopy-medicaid's config (config.rs:9) has only
// rules_url + persons_url; there is no SNAP/TANF/eligibility client
// to query source-state. SNAP-closure handling remains a documented
// follow-up (snap.case_closed publisher gap).
Ok(EleSchedulerCheckResult { processed, errors })
}).await
}
Wired into main.rs via tokio::spawn(async move { loop { let _ = run_ele_renewal_tick(…).await; tokio::time::sleep(Duration::from_secs(86400)).await; } }) for daily cadence.
Age-out query uses the denormalized ele_status.child_date_of_birth column. No cross-service SQL:
SELECT id, child_person_id, household_id, child_date_of_birth
FROM ele_status
WHERE current_status = 'active'
AND child_date_of_birth + (max_age * interval '1 year') <= now();
The subscriber writes child_date_of_birth on every ele_grant_events insert/upsert, refreshing the denormalization from the most-recent canopy-persons fetch.
Demo script (Plan 2 partial-demo path)
Without Plans 1 + 3, against canopy-seed demo profile extended with Plan-2-MR7 consent seed:
0:00 Log in as jane.snap-worker.
0:20 Run determination on Maria Lopez's SNAP application → approved.
0:40 snap.application_approved fires → canopy-medicaid express-lane
subscriber consumes → ele_consents check passes (seeded) →
ele-grant-2026.json JDM evaluates → ele_status row inserted
with eligibility_tier=medicaid, expires_at=now+12mo →
ele.granted outbox emit.
1:30 Open Liam's case detail. Identity hero shows "ELE active for
1 children until 2027-05-27" badge (loaded via
GET /v1/ele/household/{household_id}).
2:00 Log out. Log in as jane.tanf-worker. Approve Maria's TANF app.
tanf.application_approved fires → subscriber finds existing
ele_status row → ele-grant-2026.json returns "extend" with
same expires_at → ele_grant_events row inserted (extended;
source_program=tanf, source_application_id set) →
granting_program_history appends 'tanf' → ele.extended emit.
2:45 Reload Liam's case detail. Expiry date UNCHANGED. Badge
tooltip: "granted by SNAP + TANF" (history projection).
3:15 GET /v1/ele/chain-status → returns valid=true.
3:30 Optional: cargo xtask demo verify → ADR-025 validator passes.
4:00 Cut. (Plan 2 partial demo: ~4 min on top of Plan 1's ~4.5 min.)
Verification
cargo xtask dev refresh
cargo xtask seed --profile demo --reset
# DB sanity
psql -c "SELECT COUNT(*) FROM ele_consents WHERE revoked_at IS NULL;"
# Expect: 3 (Maria, Carlos, Tanya seeded by MR7)
psql -c "SELECT COUNT(*) FROM ele_status WHERE current_status='active';"
# Expect: 3+ (one per qualifying child)
psql -c "SELECT COUNT(*) FROM ele_grant_events;"
# Expect: 3+ ('granted'); after TANF re-walk, 3 more 'extended'
# API surface
TOKEN=$(./scripts/get-token.sh jane.snap-worker)
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8085/v1/ele/household/{household_id}
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8085/v1/ele/chain-status
cargo xtask rules check
cargo nextest run -p canopy-medicaid -p canopy-applications -p canopy-web \
-p canopy-common -p canopy-contracts-medicaid -p canopy-auth
cargo xtask e2e -- ele-grant.spec.ts
cargo xtask validate
cargo xtask policy audit
cargo xtask policy audit-literals
cargo xtask policy audit-unwraps
cargo xtask demo verify
Acceptance: all gates pass; ele-grant.spec.ts walks SNAP-approval → ELE granted → identity-hero badge visible → TANF-approval → ELE extended (expiry unchanged) → chain integrity verified; ≥12 new test cases on canopy-medicaid (subscriber + store + scheduler + endpoints), ≥4 on canopy-common (hash-chain compute + lock-id derive), ≥3 on canopy-applications (consent recording + new endpoint), ≥3 on canopy-web (badge render), ≥1 on canopy-auth (new Claims guard).
Risks & mitigations
-
JDM-can’t-call-Rust constraint: All decision inputs MUST be in the JDM payload. Mitigation: §7 spells out the full input schema for each ruleset; MR4 includes a unit test that POSTs each input shape to canopy-rules and asserts the output schema.
-
Hash-chain advisory-lock contention (same DB as FTI but separate lock ID): Mitigation: distinct lock-name
canopy-medicaid.ele_chainSHA-256-derives to a different i64 fromcanopy-medicaid.fti_chain. Verified by a unit test that asserts the two IDs differ. -
Time-dependent SQL: All partial unique indexes use stable predicates (
revoked_at IS NULL,current_status = 'active'). -
Subscriber idempotency: re-delivered events MUST NOT double-grant. Re-delivery is caught upstream by canopy-mq’s
event_inboxtable (existing infrastructure per ADR-018).ele_grant_events.UNIQUE(event_hash)provides a DIFFERENT guarantee: it catches chain tamper / replay attack at the database layer. -
PAMMS citation drift (grant_months value): if Georgia adopts a different ELE grant period than federal 12 months, jurisdiction.toml overrides. Mitigation: MR4 includes citation entry; ADR-011 audit ensures traceability.
-
canopy-seed bypass: MR7 seed writes ele_consents via raw INSERT. Mitigation: payloads validated against canopy-contracts-medicaid’s
EleConsent::new()constructor at seed-write time; ADR-025 validator passes post-seed. -
Consent-after-approval event race (surfaced in MR5 review): the express-lane subscriber acks-and-skips when no consent is on file, so an approval processed before
application.ele_consent_recordedlands loses the grant with no retry. Low-risk in v1: production order is consent-then-approval by construction (consent at submission, determination later). Robust re-evaluation (re-run ELE when consent lands, or bounded nack-for-retry) is tracked in #649 (post-Plan-2). The MR5 e2e masks the race deterministically by re-publishing the approval in its poll loop.
References
ADRs to honor
-
ADR-003: Ruleset as Data (central principle)
-
ADR-014: FTI Audit Hash-Chain Integrity (parallel pattern)
Existing code to extend
-
services/canopy-medicaid/src/express_lane.rs— existingcheck_express_lanestays; Plan 2 reuses -
services/canopy-medicaid/src/main.rs:264-392— extend ELE subscriber per §9 -
services/canopy-medicaid/migrations/20260417000000_create_express_lane_evaluations.sql— baseline (unchanged) -
crates/canopy-common/src/fti_audit.rs:76-125,315-370— pattern to mirror forele_audit.rs -
crates/canopy-db/src/advisory.rs:46-52—advisory_lock_idhelper -
crates/canopy-reference/src/cross_program.rs:114-123— extendExpressLaneParams -
rulesets/federal/cross-program-2026.json— extend with grant_months -
rulesets/federal/citations.toml:188-217— sibling express_lane citations -
services/canopy-renewals/src/scheduler.rs:89—run_with_advisory_lockpattern to mirror -
services/canopy-web/src/case_detail/templates.rs:29-47—CaseIdentityHeroto extend -
services/canopy-web/src/api/case_detail.rs:914-1000—build_identity_heroto extend -
services/canopy-web/templates/case_detail/_identity_hero.html— badge add -
services/canopy-applications/src/api/mod.rs— append new route forPOST /v1/applications/{id}/ele-consent(NOT aCreateApplicationRequestextension) -
crates/canopy-auth/src/claims.rs:240— pattern to mirror for newrequire_service_or_applicant_or_caseworker_or_above -
tools/canopy-seed/src/demo/personas.rs:380— Maria archetype to extend with ele_consents row
Out of scope (handled by other plans or post-Plan-2)
-
Applicant-side consent capture UI (Plan 3 — applicant portal)
-
Worker-side ELE-status display/manage UI beyond the identity hero badge (post-Plan-2 follow-up)
-
CAPS/WIC as ELE source programs (meta-plan limits to SNAP+TANF for demo)
-
T-MSIS reporting changes for ELE grants (out of demo scope)
-
snap.case_closedandpersons.income_changedpublishers (Plan 2 files follow-up issues)