Plan: Build + verify the worker SNAP+TANF+ELE demo workflow (+ applicant closure)
On this page
- Status
- Context & scope reframe
- Verified build gaps (each: file:line + decided fix)
- Phased MRs (build → seed/infra → test)
- Test-design principles (Phase 3)
- Shared conventions (every MR)
- Issues to file (ADR-013 — by number, not buried in this plan)
- Verification (per MR)
- Risks / verify-during-impl (expect more — we handle as a team)
This plan is a living guide, not a frozen spec. The worker workflow has never run end-to-end, so implementation will surface more interconnected, code-dependent issues. That is expected and fine — we work it as a team: when something new appears, resolve it, update this plan’s Design section (ADR-013: plans are living specs), and keep going. The phased structure below is the map; reality during each MR refines it.
Status
| MR | Description | Status |
|---|---|---|
1 (G1) |
Intake section forms/BFF emit valid nested payloads ( |
Done (2026-06-01) — |
2 (G2) |
NOA generation: add recipient |
Done (2026-06-01) — both determination events carry the HoH |
3 (G3) |
Verification resolves in the worker flow: on document Accept, resolve the linked verification (or a worker Resolve affordance). |
Done (2026-06-01) — chose auto-resolve-on-Accept (one worker action closes both). canopy-verification |
4 (G5) |
Determination records back + parent |
Done (2026-06-01) — the orchestrator’s |
5 (G7) |
TANF events carry the canopy-applications application id (cross-service referential integrity for notices/ELE/ |
Done (2026-06-01) — |
6 (G8) |
Surface the HoH name (discovery marker) on My Queue rows. |
Done (2026-06-01) — added an Applicant column (HoH name) to the My Queue dashboard panel + the case-search queue table, resolved through canopy-persons via the same path as the case-detail identity hero, deduped per household + fetched concurrently ( |
7 (G9 — discovered) |
Orchestrator infers TANF deprivation ( |
Done (2026-06-01) — newly-discovered 9th gap (see Design note below): the orchestrator-side |
8 (G6 + #654 seed) |
Pin the demo reference date + teach the generator to emit the ELE SQL; add the fixed-UUID coherent ELE-determination persona (2-person, dependent child w/ deprivation, zero income, SNAP+TANF, |
Done (2026-06-01) — |
9 (G4 infra) |
|
Done (2026-06-01) — added a |
10 |
Reusable |
Done (2026-06-01) — extended the demo-gated |
11 |
Worker SNAP+TANF+ELE determination walk — the honest demo (queue-discovery by HoH-name marker, two worker contexts, queue lifecycle, exact ELE event chain) on the seeded Okafor case + the 5 walk helpers. The worker reviews the applicant-populated case and determines directly — no fact-authoring theatre. Closes #654. |
Done (2026-06-02) — |
Context & scope reframe
A coverage audit + four review passes (last three by the user, against the codebase) established that the demo’s worker SNAP+TANF+ELE deep-dive is not fully built — it has broken/incomplete links no E2E exercises, which is why the coverage gaps existed. "Robust coverage" therefore means build the missing links, then prove them with tests — a multi-service build program. The demo itself would break on these gaps when recorded.
User decisions: (1) Full build-out program. (2) Queue/business-label discovery for the worker walk (real My Queue UI; no hardcoded demo UUIDs / Rust-const imports into TS). (3) Iterate this plan as the team’s guide, surfacing more as we go.
G9 (discovered building MR8’s persona): orchestrator drops TANF deprivation
Designing the coherent TANF-approving persona surfaced a 9th gap the original audit missed. For a live TANF determination (worker "Run determination" → orchestrator → canopy-tanf) to approve, canopy-tanf needs deprivation_type + deprivation_verified=true + dependent_children≥1. But the orchestrator-side ApplicationContext (canopy-contracts-eligibility) had no deprivation fields at all, nothing in canopy-applications/intake captures deprivation, and the orchestrator read none — so canopy-tanf received None/None and always denied (r-no-deprivation / r-dep-not-verified). No seed data can fix this (the persona’s deprivation never reaches canopy-tanf). It is a hard prerequisite for the MR11 walk’s "TANF approves" beat + the ELE granted→extended chain (which needs tanf.application_approved).
Decision (user, 2026-06-01): the orchestrator infers deprivation from household composition as a provisional, demo-grade simplification — a single caretaker with ≥1 minor child → continued-absence (CSO), verified; dependent-child count from member ages. This is deliberately not policy-correct (real deprivation is multi-factor + worker-verified per 45 CFR 261 / PAMMS 1510-1515); it is flagged in code + docs and tracked to explicit intake capture in #669. Built as MR7 (plumbing) ahead of MR8’s persona (overlay), keeping the two concerns separate.
This is Plan 4 of the demo-video split: Plan 1 (worker intake + program independence) and Plan 2 (ELE 1-year flag) are archived; Plan 3 (applicant portal, Dioxus) is complete. Plan 4 closes the gaps that keep the worker SNAP+TANF+ELE narrative + the applicant→worker closure from actually running end-to-end.
Verified build gaps (each: file:line + decided fix)
G1 — section forms/BFF emit invalid payloads. HouseholdCompositionPayload.members is #[validate(length(min=1))] (crates/canopy-contracts-applications/src/sections.rs:211); income_employment/resources/expenses_shelter verifications are min=0 but the field must be present (L284/317/361). The minimum-viable forms (services/canopy-web/templates/applications/_intake_section_form.html) collect none; the BFF flat→JSON converter (services/canopy-web/src/api/applications.rs ~L1462) doesn’t synthesize them → PUT 422. hx-ext="json-enc" is unbacked (the BFF parses form-encoding). Fix: pass household-membership context into the intake render/submit (a pure converter can’t derive members) so household_composition emits ≥1 members; the BFF emits verifications: [] for the 3 min=0 sections (or add #[serde(default)] to the contract); settle the encoding explicitly. Contract round-trip + live UI-save tests.
The live section-save was broken by four stacked defects — and the load-bearing one is not the encoding. Running the real htmx path in an E2E (which cargo xtask validate does not do — it stops at nextest + doctest + docker build) was required to find it; the unit tests + validate were all green while the browser save was still 100% broken.
-
hx-target(the actual root cause). The form’shx-target="closest .intake-section__form-status"points at a<div>that is a descendant of the form, butclosestwalks ancestors — so htmx raisedhtmx:targetErrorand aborted the request before it left the browser. No request ever reached canopy-web (confirmed via apage.on('console')probe:htmx:targetError, zero/sections/requests, zero handler logs). Fixed tofind .intake-section__form-status(htmx’s first-matching-descendant). This is why no worker has ever saved a section from the UI — the plan’s original "415 at the Form extractor" was a correct-but-secondary diagnosis (the request never got far enough to 415). -
Encoding (secondary).
hx-ext="json-enc"is not loaded anywhere, and the form also sethx-headers='{… "Content-Type":"application/json"}'; once the request does fire, that urlencoded-body-under-JSON-content-type would 415 ataxum::extract::Form. Both removed so htmx posts cleanapplication/x-www-form-urlencoded; the BFF stays the JSON-shaping authority. -
household_composition.members— synthesized server-side in the proxy from canopy-persons membership (person_id+relationship→relationship_to_head); the render prefillshousehold_id(readonly) + head (editable) so no UUID is hand-typed (the MR10 discovery principle). Head-only fallback keepsmin=1when membership is unreachable. -
verifications(3 min=0 sections) —#[serde(default)]on the contract (a missing list →[]for every client), not a BFF patch.
Infra fix folded in (fix-as-encountered): the devstack refresh (xtask/src/devstack_guard.rs) hashed only .rs for its source-staleness check, so the .html template edit never triggered a canopy-web rebuild — cargo xtask e2e kept running the old binary and the income-save test failed identically twice until the binary was actually rebuilt. The source hash now covers .rs and .html (Askama compiles templates into the binary). Without this, every future template edit in this plan would silently test stale code.
Live proof: 22/22 intake-partial-demo pass in both snap-worker + tanf-worker (income_employment saves through the real htmx form → success banner; household_composition render-prefill is a UUID). The existing 5 specs still pass (the income save targets a section no other spec asserts seeded-state on).
Observed, deferred to MR7 (seed coherence, not MR1): in the generated default e2e seed, some applications reference a household_id that 404s in canopy-persons (canopy_web::api::applications "failed to fetch household"), so the head prefill renders empty there. MR1’s code handles it gracefully (readonly household_id from the app still shows; head-only/empty fallback). The coherent 2-person ELE persona MR7 builds must have a real, resolvable household so household_composition synthesizes a non-empty members. Tracks with G6/G7.
G2 — NOA won’t generate. determination.completed.snap lacks person_id (services/canopy-snap/src/events.rs:48-57); the notices subscriber reads person_id from the event and early-returns if missing (services/canopy-notices/src/main.rs:154-157); the manifest routes SNAP but TANF emits tanf.determined, not determination.completed.tanf (manifest.toml:261). canopy-notices has no persons client (config.rs:18). Fix (decided): add the recipient person_id (HoH) to the determination events in canopy-snap/tanf (leaner than wiring a persons client into notices); route tanf.determined in the notices manifest. Subscriber test for both programs.
G3 — verification never resolves in the worker flow. POST /v1/verifications/{id}/resolve exists (services/canopy-verification/src/api/verifications.rs:114) but canopy-web doc-accept (services/canopy-web/src/api/actions.rs:349) never calls it. Fix: on document Accept, resolve the verification(s) whose response attached that document_id (find via canopy-verification responses) — or add an explicit worker Resolve affordance (decide UX in impl). Wire + test.
G4 — full-profile e2e is not guaranteed. services/…/xtask/src/cmd/e2e.rs hardcodes --profile snap-only (L191-202, L206); ensure_ready/auto_refresh reuse the stored profile (xtask/src/devstack_guard.rs:751); canopy-e2e.depends_on is web+portal only (docker-compose.yml:1220). Fix: a --devstack-profile <snap-only|full> flag that makes readiness profile-aware (bring up the requested profile’s services), swaps the e2e compose profile, and does NOT add unconditional depends_on on full-profile services (that would break snap-only runs) — instead an explicit full-stack health-wait for the full run; forward CANOPY_E2E_DEVSTACK_PROFILE=full for MR10’s gate.
G5 — determination result is never recorded; the queue never clears. run_determination (services/canopy-web/src/api/applications.rs:866) calls eligibility + re-renders the tab but never persists the result; the store status update doesn’t recompute the parent application status (store/mod.rs:510). So after Complete Data Collection + determination the application stays processing forever and the household never leaves the queue — the core worker beat doesn’t complete. Fix: wire run_determination to record the determination into canopy-applications and recompute the parent applications.status from the program statuses so a terminal decision clears the queue. Tests assert the status transition + queue removal.
G6 — the seed isn’t regenerable. tools/canopy-seed/src/demo/generate.rs:87 uses Utc::now().date_naive() (non-deterministic), and the committed devstack/demo-dataset/canopy_medicaid.sql ELE block is hand-edited (the generator doesn’t emit ele_* SQL — sql_extras.rs:234), so cargo xtask demo regenerate would churn dates and wipe the ELE block. Fix (prerequisite for MR7): pin a fixed demo reference-date constant in the generator; teach the generator to emit the ELE consent + TRUNCATE SQL so regenerate reproduces it and cargo xtask demo check-drift passes.
G7 — TANF crosses service boundaries with the wrong id. TANF events carry the TANF-local application_id (services/canopy-tanf/src/api/handlers.rs:118, events.rs:37); medicaid stores it as source_application_id (main.rs:508); but cargo xtask demo verify + ELE expect canopy_applications.applications.id (xtask/src/cmd/demo.rs:319). Fix: TANF events must carry the canopy-applications application id (normalize, or add a distinct canopy_application_id field) so notices/ELE/demo-verify referential integrity holds — without breaking program isolation (ADR-001/002).
G8 — My Queue exposes no discovery marker. Queue items carry no applicant/HoH name/marker (services/canopy-web/src/…/my_queue.rs:21); templates render case/type/program/status/due/action only (search.html:18). Queue-discovery (the chosen test approach) can’t find the persona. Fix: surface the HoH name (or a household label) on My Queue rows (row data + template) so the walk discovers the case by a stable business marker — also a genuine worker-UX improvement.
Phased MRs (build → seed/infra → test)
Order within a phase is flexible; phases are sequential (tests need the build + seed + infra).
Phase 1 — build the missing links
-
MR1 (G1): intake section forms/BFF nested payloads + household context.
canopy-web(applications.rs,_intake_section_form.html), maybecanopy-contracts-applications. Labels:type::feature,priority::high,service::web,program::cross-program,workflow::in-progress. -
MR2 (G2):
person_idon determination events +tanf.determinednotices routing.canopy-snap,canopy-tanf,canopy-notices. Labels:type::bug,priority::high,service::notices,program::snap,program::tanf. -
MR3 (G3): verification-resolve on doc-accept.
canopy-web/api/actions.rs+ Verifications section →canopy-verification. Labels:type::feature,priority::high,service::web,service::verification,program::cross-program. -
MR4 (G5): determination records back + parent application-status recompute → queue clears.
canopy-web,canopy-applications. Labels:type::bug,priority::critical,service::web,service::applications,program::cross-program. -
MR5 (G7): TANF events carry the canopy-applications application id.
canopy-tanf. Labels:type::bug,priority::high,service::tanf,program::tanf,compliance::irs-pub-1075-audit. -
MR6 (G8): HoH-name marker on My Queue rows.
canopy-web. Labels:type::feature,priority::medium,service::web,program::cross-program,compliance::wcag-21-aa.
Phase 2 — seed + infra
-
MR7 (G6 + #654 seed): pin the demo reference date + teach the generator to emit ELE SQL; add the fixed-UUID persona (Rust
render_*_supplementinsql_extras.rs+bin/demo.rs, thencargo xtask demo regenerate+ commit +check-drift✓): a 2-person household (adult HoH + a dependent child with a deprivation basis so TANF approves — e.g. absent-parent), no income rows, a SNAP+TANF applicationsubmitted/programspending, no pre-completed sections, a distinctive HoH last-name marker for MR10 discovery (distinct from the existing phase9 app). The generator-emitted democanopy_medicaid.sqlTRUNCATEsele_consents+ele_status+ele_grant_eventsand seeds oneele_consentsrow (consent INPUT only — chain state is derived live per ADR-014) using the persona’s fixed UUIDs (sodemo verify’s orphan checks pass); the TRUNCATE gives deterministic ELE-clean loads so MR10’s granted-then-extended is reproducible. Labels: `type::feature,priority::high,service::seed,program::cross-program. Docs: add identifiers torunbooks/demo-applicant-credentials.adoc. -
MR8 (G4 infra):
--devstack-profile full+ profile-aware readiness + a full-stack health-wait (NOT unconditionaldepends_on).xtask/cmd/e2e.rs,devstack_guard.rs,docker-compose.yml. Labels:type::chore,priority::high,service::ci,service::xtask,program::infrastructure. ADR-005: additive, demo-gated, full-present path.
Phase 3 — tests (the proof)
-
MR9 (helpers + applicant closure): reusable
tests/e2e/lib/helpers —fillSection,completeDataCollection,runDetermination,pollNotices,pollEleBadge,assertAuditEvent,findInQueueByMarker— with 30–60 s polling + diagnostics. Extendapplicant-portal.spec.ts: applicant attaches the uploaded doc to the verification → worker Accepts → assert doc "Accepted" + (G3) the verification resolves; the applicant side reflects "Accepted" keyed off the document row/metadata, not the filename. Labels:type::feature,priority::high,service::ci,service::web,service::portal,program::cross-program. -
MR10 (worker SNAP+TANF+ELE walk): a new demo-gated
tests/e2e/specs/worker-determination-ele.spec.ts+ project gated onCANOPY_E2E_SEED_PROFILE==='demo'ANDCANOPY_E2E_DEVSTACK_PROFILE==='full', added to the default caseworkertestMatchexclusion (playwright.config.ts:46). Two browser contexts (auth/snap-worker.json,auth/tanf-worker.json) — one project can’t hold both. The walk:-
SNAP worker → My Queue → find the household by the MR6 HoH-name marker (asserts queue visibility; no hardcoded UUID) → Run Determination via the shared top-bar action → assert
.u-hero-status"APPROVED" +.u-hero-amount/\$[\d,]/+. (Honest: the facts are the applicant-submitted/seeded canopy-persons data — the worker reviews and determines; there is NOfillSection/Complete-Data-Collection beat, because that intake JSON the determination never reads is exactly the theatre epic &56 / ADR-027 exists to replace. Run Determination has no data-collection gate.) -
pollNotices→ SNAP NOA (G2);pollEleBadge→ "ELE active for 1 child until {date}", capture the date. -
TANF worker (a second context) → My Queue still shows the same household’s TANF item (NOT lost by the SNAP determination — a multi-program app leaves the queue only once every program is terminal, since My Queue filters on application status; the original "leaves the SNAP queue after SNAP" was wrong) → Run Determination → APPROVED (via the seeded single-caretaker + minor-child deprivation, orchestrator-inferred per #669, provisional) → TANF NOA (G2) → now both programs terminal → the app is
determinedand clears the queue for both workers. -
ELE:
assertAuditEventfor the exact names —snap.application_approved→medicaid.ele.granted→tanf.application_approved→medicaid.ele.extended(the audit section is per-household with no program column — assert the event_types appear; the per-program-chain separation is a backend/ADR-014 property, not UI-asserted) — deterministic because MR7 loads ELE-clean; assert the until-date unchanged and theextendedevent fired (the date alone is insufficient).Labels:
type::feature,priority::high,service::web,service::ci,program::cross-program,compliance::wcag-21-aa,compliance::irs-pub-1075-audit. Docs: flip theele-badge.spec.ts#654 deferral comment; update theroadmap.adocRemaining Work Tracker; note the walk in the Antora e2e page.
-
Test-design principles (Phase 3)
Queue/business-marker discovery (no UUID/Rust-const imports into TS); two browser contexts for the two worker roles; exclude the new spec from the default project; exact event-name assertions; 30–60 s shared polling helpers with diagnostics (RabbitMQ/outbox/subscriber on a cold full stack); decouple from the exact demo filename; assert queue lifecycle (appears, not-lost-across-programs, clears) — not just NOAs/events.
Shared conventions (every MR)
Branch from synced main; targeted git add; a fresh D1–D8 subagent on the staged diff reported inline; a two-stage PRECOMMIT_TOKEN=<token> git commit (never --no-verify); the commit trailer Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>; cargo fmt --all; a pre-push cargo xtask validate; glab mr create; force-merge = cancel-MWPS POST (406 ok) + PUT merge squash=false&should_remove_source_branch=true (workflow-conventions.md overrides the Tier-1 "never force-merge"); sync main + update memory after each merge; Closes #654 on MR7/MR10. Labels per-MR (the type:: closed set has no test → use feature/bug/chore). Canonical docs land in Antora (CLAUDE.md doc-homes); a CHANGELOG bullet every MR. Keep MRs ≤500 LOC (split where the regenerated-SQL diff or a multi-service change pushes over).
Issues to file (ADR-013 — by number, not buried in this plan)
-
type::chore: assertion-hardening of the "response < 500" worker-action specs (actions.spec.ts,worker-portal-*-actions.spec.ts,renewals.spec.ts). -
type::feature(optional): a worker ELE-consent capture affordance (none exists today; the demo consent is seeded). -
#654 is closed by MR10; #667 (tabs-shell
get_tabcomposition gap) stays related (the walk uses?focus_section=).
Verification (per MR)
-
MR1: contract round-trip + a live
PUT …/sections/snap/household_compositionreturns 200, not 422. -
MR2: a notices subscriber test emits a SNAP and a TANF NOA with a resolved recipient.
-
MR3: accepting a doc resolves the linked verification (gone from
?status=pending). -
MR4: after determination the application status transitions terminal + the household leaves the queue.
-
MR5: a TANF event’s
application_idresolves tocanopy_applications.applications.id;demo verifyclean. -
MR6: My Queue renders the HoH-name marker.
-
MR7:
demo regenerateis byte-stable (pinned date) +check-drift✓;demo verify✓;--profile demo --resetloads clean twice; a seed test pins SNAP-approves + TANF-approves + ELE-gate-passes + ELE-clean load. -
MR8:
cargo xtask e2e --devstack-profile fullbrings the full stack healthy from a snap-only starting stack; snap-only e2e still works. -
MR9:
cargo xtask e2e --profile demo — --project=applicant-portal— Accept resolves the verification; the applicant reflects Accepted (row-keyed). -
MR10:
cargo xtask e2e --profile demo --devstack-profile full — --project=worker-determination-elegreen — queue-discovered SNAP+TANF determinations APPROVE, the queue clears, NOAs generate, ELE granted-then-extended (exact events). A deliberately-wrong assertion fails (sanity); theele-badge.spec.tsabsent-case guard still passes. -
Every MR: a D1–D8 staged-diff review; a pre-push
cargo xtask validate.
Risks / verify-during-impl (expect more — we handle as a team)
-
G1 members source (household context into render/submit); serde-default vs explicit empty arrays.
-
G2 HoH
person_idavailability in the determination handler at publish time. -
G3 doc↔verification link + the resolve UX.
-
G4 profile-aware readiness without disrupting a running stack; no unconditional full
depends_on. -
G5 the right place to record + recompute status (web vs applications) + queue-removal semantics.
-
G6 pinned-date scope (does it churn other archetypes?) + the generator’s ELE-SQL emission.
-
G7 cross-service ID normalization without breaking program isolation (ADR-001/002).
-
TANF approval needs real deprivation/dependent-child context flowing from the filled sections.
-
ELE idempotency via the demo-SQL TRUNCATE of the three ELE tables (the chain tables are excluded from
--resetper ADR-014; the demo SQL’s own TRUNCATE is the reset mechanism). -
fulle2e cost/time; keep it demo-gated + out of the default validate e2e. -
More will surface — that is expected; resolve, update this plan’s Design section, continue.