Plan: ELE 1-year-flag expansion (SNAP+TANF demo video)

On this page

Status

MR Description Status

1

feat(contracts+common): ELE wire contracts + EleConsentId/EleStatusId/EleGrantEventId newtypes + paths constants + compute_ele_event_hashcrates/canopy-contracts-medicaid extensions (EleConsent/EleStatus/EleGrantEvent wire types + 5 event payload structs + paths constants); three new typed-ID newtypes via define_id! macro in crates/canopy-common/src/id.rs; NEW crates/canopy-common/src/ele_audit.rs mirroring fti_audit.rs:76-125.

Done (2026-05-29) — #642. Added EleChainStatus wire type + canonical_payload_json helper + routing_keys (natural elaborations). Lock derivation uses the extant canopy_db::advisory::advisory_lock_id("canopy-medicaid.ele_chain") (acquired MR2), not a new helper. 12 tests.

2

feat(medicaid): ele_consents + ele_status + ele_grant_events migrations + store layer + sqlx-typed inserts — three forward-only migrations; store layer with hash-chain-locked INSERT (mirror fti_audit.rs:315-370); ADR-025 cross-service ref validator entries; utoipa annotations.

Done (2026-05-29) — #643. store::ele (Row structs + TryFrom<Row> enum-parse conversions + insert_ele_grant_event/upsert_ele_status/has_active_consent/insert_ele_consent/current_ele_status/find_*). Design refinements (see §Schema notes): occurred_at is app-set (Utc::now()) after lock acquisition (not the DEFAULT) so the hashed value matches the stored value and stays monotonic — avoids the Bug-7 fork without a separate clock column; chain index is (occurred_at, id) (uuid-v7 tiebreaker) instead of (previous_hash); 7 of 8 ADR-025 ledger entries added — source_determination_id deferred to MR5 (its SNAP-determination source DB is confirmed when the grant subscriber populates it; no rows until then). 3 unit tests (lock-id-differs + 2 conversion); store DB-path exercised end-to-end by MR5’s subscriber + chain-status tests.

3

feat(applications+medicaid+auth): dedicated POST /v1/applications/{id}/ele-consent endpoint + new Claims guard + application.ele_consent_recorded event + canopy-medicaid subscriber + ele_consents writes — NO CreateApplicationRequest extension, NO applications-table change. NEW dedicated endpoint + NEW require_service_or_applicant_or_caseworker_or_above() Claims guard. canopy-medicaid subscriber populates ele_consents.

Done (2026-05-29) — #644. EleConsentRequest (consent_source/language/notes, deny_unknown_fields) + ELE_CONSENT path; handler gates status ∈ {submitted, processing, data_collected} (404/409/422), derives consent_recorded_by from the principal (never the body — worker subject UUID, else the applicant submitter), emits application.ele_consent_recorded (PII-allowlist). consent_source kept a String in the contract (handler-validated 422) to avoid a contracts→contracts dep. New require_service_or_applicant_or_caseworker_or_above() guard. canopy-medicaid canopy-medicaid.ele-consent subscriber → store::ele::insert_ele_consent. 1 auth unit test + 3 applications integration tests (202/422/404).

4

feat(rules+reference): three JDM rulesets (ele-grant + ele-renewal + ele-lapse) + cross-program grant_months extension + federal citation — Rust subscriber pre-computes date candidates (no date math in JDM per medicaid-tma-phase.json:5 precedent); ExpressLaneParams.grant_months with #[serde(default = "default_grant_months")]; citation in rulesets/federal/citations.toml.

Done (2026-05-29) — 645. Three federal rulesets (ele-grant-2026 / ele-renewal-2026 / ele-lapse-2026), inputNode→decisionTableNode(hitPolicy "first")→outputNode, each with a _comment citing 42 CFR 435.1102 + SME-pending §2069. Design deviations (see §JDM rulesets): (a) income arrives as precomputed input.income_pct_fpl, not raw cents + FPL base — the * 100 percentage conversion would trip rules lint-inputs (ADR-011 bare-literal gate), so MR5’s Rust subscriber owns that arithmetic exactly as it owns the date arithmetic; (b) existing_ele_status object → boolean input.has_existing_active_ele — a decision-table cell can’t null-check a nested object, and grant-vs-extend only needs the presence bit; (c) ele-lapse is durability-biased — source-close and income-change both resolve to keep (continuous eligibility), lapse only on aged-out/residency-loss/death, so the named ruleset legitimately keeps in the wired cases; (d) renewal extend carries use_new_dates=true (resets the clock for the new year) vs grant-time extend which keeps the prior expiry. grant_months: u32 ([serde(default = "default_grant_months")] = 12) + "grant_months": 12 in cross-program-2026.json + [citations."cross-program-2026.express_lane.grant_months"] (42 CFR 435.1102). 3 rules check fixtures (all pass) + 2 cross_program unit tests (loads_from_federal_json asserts 12 + a serde-default test). lint-inputs/policy audit/rules check all green.

5

feat(medicaid): MedicaidRulesClient::evaluate_ele_grant + extend express-lane subscriber + GET /v1/ele/{person_id} — capture ele_publisher/ele_rules_client/ele_service_token BEFORE main.rs:141 .layer(Extension(boot.publisher)). Uses existing MedicaidRulesClient::evaluate_namespaced pattern. Replaces invented HTTP path with verified-extant routes. NEW GET /v1/ele/{person_id} + GET /v1/ele/chain-status.

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 78a4edd0): a pure-Rust express_lane.rs::check_express_lane tier ladder + an express_lane_evaluations log + subscriber that was a production no-op (it GET-ed a non-existent /v1/households/{id}/members with no auth and read fields absent from the HouseholdMember contract → always logged no_qualifying_children; the e2e test codified that bug). MR5 therefore (a) DELETES express_lane.rs — the decision now runs entirely through ele-grant-2026 (the age ceiling too; no Rust age pre-filter); (b) rewrites the subscriber to fetch the household + each member from canopy-persons over service-token HTTP (ADR-019), compute only non-policy arithmetic in Rust (a new ele_grant module: income-frequency→monthly + the FPL × 100 + grant-window dates), gate on has_active_consent, call new MedicaidRulesClient::evaluate_ele_grant per member, and persist durable ele_status + hash-chained ele_grant_events + emit medicaid.ele.{granted,extended} in-tx (resolves MR2’s deferred source_determination_id/source_application_id ledger entry — SNAP carries determination_id, TANF application_id); (c) SUPERSEDES the legacy log — drops express_lane_evaluations (forward migration 20260606000000, ADR-016; never seeded), removes GET /v1/express-lane + record/list_express_lane_evaluation + the test-lib client + LIST_EXPRESS_LANE, and adds GET /v1/ele/{person_id} (typed EleStatus, 404 when none) + GET /v1/ele/chain-status (new store::ele::verify_ele_chain; 503 on break, parallel to FTI per ADR-014). The e2e test is rewritten from the bug-codifying no_qualifying_children assertion to a real grant assertion (persons seed → consent → TANF approval → child gets active Medicaid flag → adult skipped by r-over-age → chain verifies). 6 new ele_grant unit tests + the rewritten end-to-end grant test. canopy-contracts-persons added as a canopy-medicaid dep for the typed fetch.

6

feat(medicaid): tanf.case_closed subscriber + POST /v1/ele/{person_id}/revoke + follow-up issues for missing publishers — only tanf.case_closed is verified-extant; MR6 consumes that event + ships admin revoke endpoint. Files two follow-up GitLab issues for snap.case_closed + persons.income_changed publishers (don’t exist today).

Done (2026-05-29) — #647. ELE source-closure LAPSE handling is folded INTO the existing canopy-medicaid.tma subscriber’s tanf.case_closed handler (handle_ele_case_closed, run in the same transaction before the TMA block). Why one handler, not a separate group: the per-service event_inbox dedups on event_id ALONE (PK), so a second consumer group bound to the same routing key loses the inbox-INSERT race and gets AlreadyProcessed — silently never running (an initial "separate group" attempt did exactly this, starving the TMA subscriber; caught by the tma_e2e_test in pre-push validate). The handler finds the household’s active ele_status rows, and for each flag the closing program (TANF) granted, routes the decision through the ele-lapse-2026 JDM ruleset (ADR-003 — evaluate_ele_lapse, no hardcoded thresholds), passing trigger_event="source_closed" + has_other_active_source (derived from the projection after removing the closed program). The ruleset is durability-biased, so source closure resolves to keep: the flag stays active and only the granting_program_history projection drops TANF (set_ele_granting_history); the lapse branch (transition to lapsed + hash-chained lapsed event + medicaid.ele.lapsed emit, via new transition_ele_status) is wired for forward-compat but unreachable under the federal ruleset. Design deviation (recorded here per ADR-013): granting_program_history is treated as currently-active granting sources (not cumulative ever-granted) — forced by ADR-001 (canopy-medicaid has no SNAP/TANF state client; the projection IS the only has_other_active_source signal), so closure removes the closed program. Contract doc on EleStatus.granting_program_history updated for honesty. NEW admin endpoint POST /v1/ele/{person_id}/revoke (require_admin_or_quality_control, EleRevokeRequest{reason}): flips the active flag to revoked, appends a hash-chained revoked event carrying the acting principal as actor_id, and emits medicaid.ele.revoked in one tx; 400 on empty reason, 404 when no active flag. New publish_ele_lapsed/publish_ele_revoked (minimal PII-allowlist payloads matching the MR1 contract shapes). 2 ruleset-drift unit tests (ele-lapse-2026 threshold + input) + a 2-test live e2e (ele_lapse_e2e_test.rs: TANF approval grant → tanf.case_closed → durable-keep with TANF dropped from history + chain valid; admin revoke 200 / caseworker 403 / revoked flag 404s + chain valid). Two follow-up issues filed for the missing snap.case_closed (#651) + persons.income_changed (#652) publishers.

7

feat(medicaid+web): renewal scheduler + GET /v1/ele/household/{household_id} aggregate + canopy-web ELE badge + seed consent + Playwright partial-demo — scheduler returns Result<SchedulerOutcome<EleSchedulerCheckResult>> (preserves Skipped case). NEW aggregate endpoint per-household. CaseIdentityHero.ele_household_summary. canopy-seed extends Maria/Carlos/Tanya personas with ele_consents seed rows.

Done (2026-05-29) — #648. PLAN 2 COMPLETE. New scheduler.rs — a daily advisory-locked (canopy-medicaid.ele-renewal) tick, both passes routing the decision through JDM (ADR-003): (1) renewal-due (find_ele_status_near_expiry, 30-day window) → ele-renewal-2026 → extend (resets the 1-year clock via renew_ele_status) / lapse / pending_redetermination (set_ele_status_kind, no chain event — income unverified routes to a worker); (2) age-out (find_aged_out_children, denormalized DOB, no cross-service SQL) → ele-lapse-2026 (aged_out_exceeded) → lapse. Local EleSchedulerCheckResult{processed,errors} (no canopy-renewals dep, ADR-001). NEW GET /v1/ele/household/{household_id}EleHouseholdSummary{active_count, soonest_expires_at, children:[EleChildBrief]}. CaseIdentityHero gains ele_badge_text (precomputed server-side, Askama-0.15-safe) → meta-row pill "ELE active for N child(ren) until {date}" via the existing u-status-info token (light+dark already defined; no design round-trip). NEW admin ops endpoint POST /v1/ele/renewals/run?within_days= (require_admin_or_quality_control) — runs the sweep on demand (ops affordance + scheduler testability hook). Deviations (ADR-013): (a) the demo ele_consents seed lives in the hand-curated demo dataset (devstack/demo-dataset/canopy_medicaid.sql, 3 households-with-children), NOT the random generator — the plan’s "canopy-seed extends personas" conflated the two; seeded as preconditions only (consent), the durable flag is derived live by the grant subscriber (never fabricated — ADR-014). (b) The full approve→grant→badge Playwright walk + folding the demo seed into the random generator (so the default-seed e2e has a deterministic ELE-active household) are deferred to #654 (coherent-scenario generator initiative — the generator must not be able to emit impossible states); MR7 ships the Rust e2e (grant→summary→renewal→lapse→revoke, real pathway) + 3 badge unit tests + a graceful-render Playwright (ele-badge.spec.ts: hero renders, badge correctly absent for a no-ELE household). 4 ruleset-drift unit tests (ele-lapse + ele-renewal) + 2 new Rust e2e (household-summary + manual-renewal-tick).

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.

Pre-commit Q1-Q8

Per .claude/docs/delivery-protocol.md + feedback_precommit_questions. Per-MR Q1-Q8 answers go to stdout for user review (per feedback_no_q1q8_in_commit); not pasted into commit messages or this plan body.

Locked decisions

Decision Choice

ELE consent model

Separate explicit consent event from canopy-applications via a NEW dedicated endpoint. DO NOT extend CreateApplicationRequest or applications table — add POST /v1/applications/{id}/ele-consent endpoint that emits application.ele_consent_recorded directly. Plan 3 applicant portal POSTs after primary submission; Plan 1 worker-intake fallback POSTs the same with consent_source=worker_attestation. canopy-medicaid subscribes; populates new ele_consents table. ELE grant fires only when consent is on file. Avoids breaking existing CreateApplicationRequest struct literals at services/canopy-applications/tests/application_test.rs:100 and removes the unneeded applications-table column. For Plan 2 partial-demo, seed pre-populates consent rows directly in ele_consents.

Clock start (ADR-003 compliance)

JDM picks among Rust-precomputed date candidates. Per the existing TMA ruleset convention (rulesets/default/medicaid-tma-phase.json:5 explicitly notes JDM doesn’t express date math natively), the Rust subscriber precomputes granted_at_candidate = max(source_approval_at, child_first_qualifying_date) and expires_at_candidate = granted_at_candidate + grant_months months. JDM ruleset ele-grant-2026.json receives these as inputs and decides: (a) eligibility tier (medicaid/peachcare/none), (b) verdict (grant/extend/skip/already_active), (c) which precomputed dates apply. No date arithmetic in JDM.

Lapse triggers (all four)

(a) Child ages out — caught on daily cron tick. ele_status denormalizes child_date_of_birth so the tick query is local to canopy-medicaid (no cross-service SQL per ADR-001).

(b) TANF case closure — subscribe to existing tanf.case_closed (verified at services/canopy-tanf/src/events.rs:78-93). SNAP case closure: NO publisher exists today. Plan 2 files a follow-up GitLab issue.

(c) Worker manual revoke — new admin endpoint POST /v1/ele/{person_id}/revoke requiring Claims::require_admin_or_quality_control().

(d) Income exceeds 247% FPL — NO persons.income_changed publisher exists today. Plan 2 routes the income-lapse decision through the daily renewal-scheduler tick AND through the next determination.completed.snap/tanf.determined event. Files a follow-up issue for a real-time publisher.

All lapse decisions route through ele-lapse-2026.json JDM ruleset — no hardcoded thresholds.

Renewal model (ADR-003 compliance)

At 1-year mark, scheduler ticks via run_with_advisory_lock("canopy-medicaid.ele-renewal", …​). Query ele_status WHERE current_status='active' AND expires_at < now() + interval '30 days'. For each: fetch current household state → POST to canopy-rules /v1/evaluate (ele-renewal-2026.json) → apply decision (extend/lapse/require_redetermination). Does NOT require source program still active. Decision lives in JDM.

Storage model

Three new tables in canopy-medicaid: ele_consents (durable applicant consent), ele_status (current snapshot), ele_grant_events (append-only audit log with hash-chain integrity per ADR-014). ele_status.granting_program_history TEXT[] is a denormalized projection from ele_grant_events, maintained atomically by the subscriber. Both can be rebuilt from events; ele_grant_events is the source-of-truth.

Hash-chain (parallel to FTI per ADR-014)

New compute_ele_event_hash() in crates/canopy-common/src/ele_audit.rs mirroring compute_fti_event_hash() at crates/canopy-common/src/fti_audit.rs:76-125. Advisory lock via pg_advisory_xact_lock(ele_chain_lock_id("canopy-medicaid")). Same canonical timestamp format %Y-%m-%dT%H:%M:%S%.6f+00:00. NOT pure-FTI (this isn’t tax data); separate lock space; separate chain. Signature includes payload_canonical_json: &str so the ele_grant_events.payload JSONB column participates in the chain (tamper-evidence for future extension fields).

Cross-program parameter

New field grant_months: u32 on ExpressLaneParams (crates/canopy-reference/src/cross_program.rs:114-123). Serde-default 12 via #[serde(default = "default_grant_months")] so jurisdiction-override JSON files without the field still load. Updated in rulesets/federal/cross-program-2026.json as the canonical value.

Identity hero badge

build_identity_hero() at case_detail.rs:914-1000 currently picks members[0].person_id as the HoH (case_detail.rs:928 — Maria, not Liam). ELE status is keyed by child_person_id, so per-person lookup would miss every household’s children. Plan 2 adds a NEW aggregate endpoint GET /v1/ele/household/{household_id} returning EleHouseholdSummary { active_count: u32, soonest_expires_at: Option<NaiveDate>, children: Vec<EleChildBrief> }. CaseIdentityHero gains ele_household_summary: Option<EleHouseholdSummary>. Hero renders the badge "ELE active for {active_count} children until {soonest_expires_at}" when active_count > 0. Styling (added 2026-05-28): reuse the existing StatusPill/chip pattern with an info semantic token triple from the 23-token Orchard palette (design applicant-portal design ref §3.1), light/dark via [data-theme]; confirm the exact token with design before MR7 — the demo design package carries no ELE-badge mock.

Plan 2 ↔ Plan 3 dependency

Plan 2 MR3 ships the NEW dedicated endpoint POST /v1/applications/{id}/ele-consent in canopy-applications (no struct/table modification). Plan 3 (applicant portal) consumes this: applicant checks the consent box during the canopy-portal Dioxus flow → Dioxus POSTs to /v1/applications/{id}/ele-consent post-submission → canopy-applications emits application.ele_consent_recorded → canopy-medicaid subscriber populates ele_consents. Plan 3 must wait for Plan 2 MR3 to land. The remainder of Plan 2 (MR4-MR7: rulesets, subscribers, scheduler, badge) is parallel-trackable with Plan 3. Execution model ratified 2026-05-28: Plans 2 + 3 run SEQUENTIALLY (Plan 2 fully, then Plan 3) — so the MR3 → Plan-3-MR6 dependency is guaranteed by construction (no CI guard required), and the "parallel-trackable" option above is not exercised.

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.

NOTE

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 by handle_ele_case_closed, folded INTO the existing canopy-medicaid.tma subscriber’s handler (same group, same transaction), NOT a separate subscriber group. The per-service event_inbox dedups on event_id alone, 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 through ele-lapse-2026.json for every matching flag (not gated on "history empties" in Rust — the decision lives in JDM per ADR-003), passing has_other_active_source derived from the post-removal projection. The durability-biased ruleset returns keep, so the flag stays active and only granting_program_history drops TANF (set_ele_granting_history); the lapse branch (transition_ele_statuslapsed event → emit) is wired but unreachable under the federal ruleset.

Plan 2 files two follow-up GitLab issues (post-Plan-2 enhancements):

  1. canopy-snap snap.case_closed publisher (current gap) — filed as #651.

  2. canopy-persons persons.income_changed publisher (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-90SchedulerOutcome 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_chain SHA-256-derives to a different i64 from canopy-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_inbox table (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_recorded lands 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

Existing code to extend

  • services/canopy-medicaid/src/express_lane.rs — existing check_express_lane stays; 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 for ele_audit.rs

  • crates/canopy-db/src/advisory.rs:46-52advisory_lock_id helper

  • crates/canopy-reference/src/cross_program.rs:114-123 — extend ExpressLaneParams

  • 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:89run_with_advisory_lock pattern to mirror

  • services/canopy-web/src/case_detail/templates.rs:29-47CaseIdentityHero to extend

  • services/canopy-web/src/api/case_detail.rs:914-1000build_identity_hero to extend

  • services/canopy-web/templates/case_detail/_identity_hero.html — badge add

  • services/canopy-applications/src/api/mod.rs — append new route for POST /v1/applications/{id}/ele-consent (NOT a CreateApplicationRequest extension)

  • crates/canopy-auth/src/claims.rs:240 — pattern to mirror for new require_service_or_applicant_or_caseworker_or_above

  • tools/canopy-seed/src/demo/personas.rs:380 — Maria archetype to extend with ele_consents row

Conventions

  • .claude/docs/delivery-protocol.md — Q1-Q8 + Pre-Implementation Design

  • .claude/docs/coding-conventions.md — 7-arg ceiling

  • .claude/docs/testing.mdcargo nextest only; Playwright for E2E

  • feedback_no_q1q8_in_commit — Q1-Q8 stdout-for-user, not commit body

  • feedback_xtask_not_docker_compose

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_closed and persons.income_changed publishers (Plan 2 files follow-up issues)

Edit this page · default