Plan: Applicant intake + verification (Dioxus 0.7+ canopy-portal)

On this page

Status

MR Description Status

1a

feat(adr): ADR-008 amendments (format supersedes §82 + strict CSP supersedes §214) — two additive supersession sections; HANDOFF credential format wins; wasm-unsafe-eval is the only unsafe CSP directive allowed. ADR diff only; no code.

Done (2026-05-29) — ADR-008 gains an == Amendments section with Amendment 1 — Credential format (HH-[a-f0-9]{8} + word-word-word-NN passcode supersedes CANOPY-YYYYMMDD-XXXXXXXX + DOB; DOB never an auth factor; intimate-threat rationale) and Amendment 2 — Strict CSP (wasm-unsafe-eval the only unsafe directive; no 'unsafe-inline' for styles; MR1b zero-inline-style spike is a hard gate). Original §82 + §214 retained with forward NOTE pointers (ADR decisions are immutable). Refs #630.

1b

feat(portal): Dioxus 0.7 scaffold + CSP nonce layer + CSP+Routing spike outcome — the dioxus 0.7.9 crate (latest stable; 0.8 is alpha) with features fullstack + router (NOT separate dioxus-fullstack/dioxus-router crates). Privacy-first redesign (ADR-026): the portal is Postgres-free — drop canopy-api/db/mq + sqlx + tower-sessions-sqlx-store + the migrations/ dir + build.rs; the server-only CSP-layer crates are optional = true behind a server feature; MR1b declares only deps it uses (no redis — MR5; no draft-crypto crates — MR6). services/canopy-portal/main.rs rewritten from the Axum stub to a Dioxus fullstack app served via dioxus::server::router(App) (manual Axum router — required so the strict-CSP nonce layer can rewrite Dioxus’s inline hydration script; NOT LaunchBuilder; see §9), with CspNonceLayer + healthz + metrics and no DB / no sessions (Redis sessions land in MR5). The CSP+Routing spike resolved the [layout]/[end_layout] Route enum + the zero-inline-style question. HARD GATE — PASSED (2026-05-29): the Dioxus 0.7.9 release build emits zero inline style= / <style> (SSR + index.html), the CspNonceLayer nonces every <script>, so strict style-src 'self' + script-src 'self' 'nonce-…' 'wasm-unsafe-eval' holds with NO 'unsafe-inline' and NO hash-based fallback (#630) — MR2 component emission is unblocked. (Dev-only: dx’s debug hot-reload toast injects an inline <style> + a Google-Fonts @import, stripped in release.) No applicant_sessions migration (sessions are Redis-primary per ADR-026; the portal owns no database). The shared deliverables DocumentId (canopy-common) + require_service_or_applicant_or_caseworker_or_above (canopy-auth) are already on mainomit both. Devstack coherence (folded into MR1b, 2026-05-30): excluding the portal binary from the musl image (Dockerfile --exclude canopy-portal) leaves cargo xtask dev start (default full profile) unable to run a deleted /app/canopy-portal, and canopy-portal was one of two KEYCLOAK_REALM_AFFECTING_SERVICES — an unhealthy portal destabilised the port-reconcile that bakes redirect URIs into the Keycloak realm, cascading every auth-dependent E2E test red. So MR1b also removes canopy-portal as a running devstack service: the canopy-portal compose service, its PORT_MAPPINGS + KEYCLOAK_REALM_AFFECTING_SERVICES entries (xtask/src/docker.rs), the ${env.CANOPY_PORTAL_HOST_PORT} realm/entrypoint templating, the Prometheus scrape target, and the k6 smoke-service entry. The inert leftovers (the empty canopy_portal DB + unused test-config refs) stay for MR5’s full deprovision.

Done (2026-05-29) — Dioxus 0.7.9 fullstack scaffold landed: CspNonceLayer (strict style-src 'self'), §9 Route enum, public/authed layouts (Outlet in ErrorBoundary), 9 page stubs + NotFound, class-only placeholder stylesheet, server (dioxus::server::router + healthz/metrics + Fluent) / client (dioxus::launch) split; Postgres-free; CSP zero-inline-style spike PASSED on release; 15 unit tests (csp + i18n).

1c

feat(portal): canopy-portal static stylesheetservices/canopy-portal/assets/canopy-portal.css (served via Dioxus asset!(); class-only styling primitives, Orchard tokens). The passcode wordlist originally planned here is removed — the passcode is now NNNN-NNNN-NNNN (twelve uniform-random digits, ADR-008 Amendment 3); generation lives in MR4.

Done (2026-05-30) — Orchard design-system foundation ported into services/canopy-portal/assets/canopy-portal.css, replacing the MR1b placeholder. The canonical light + dark token set from design/canopy-portal/portal-stream/lib.jsx is now expressed as 34 --portal- CSS custom properties (brand / surface / text / chrome + the success/warning/error/info semantic triples + Montserrat & JetBrains-Mono family stacks + page/btn radius + Card-glow/Btn-lift elevation tokens), and the live scaffold vocabulary (shell, page card with the gold-rule title motif, lede, actions, btn/btn—​primary/btn—​ghost with native :hover/:active + accent-gold :focus-visible, skip-link, error fallback) is restyled to Orchard fidelity. WCAG 2.1 AA ≥44 px tap targets + the prefers-reduced-motion guard are enforced in CSS. *Scope notes: dark mode is @media (prefers-color-scheme: dark) only — the first-paint default-only signal per applicant-portal design ref §10; the [data-theme] saved-preference-wins override lands in MR2 with the theme toggle that sets the attribute. The lib.jsx component primitives (Card/StatusPill/Mono/MonoChip/Overline/GoldRule/Leaf + the full Btn variant/size matrix) are deferred to MR2 so each class ships WITH the component that renders it — adding them here would be unreferenced (dead) CSS. Self-hosted @font-face woff2 (HANDOFF rule 6) rides the asset pipeline in a later MR; the declared system fallbacks render correctly today. Strict-CSP gate preserved by construction (CSS-only edit; no .rs change → served HTML byte-identical; zero @import / off-origin url()); cargo check -p canopy-portal --features server green. Wordlist removed (passcode generation → MR4).

2

feat(portal): theme + primitives port (lib.jsx → Rust components, class-only styling) — Port design/canopy-portal/portal-stream/lib.jsx to Dioxus. Btn/Card/StatusPill/Mono/Icon components. ~23 Orchard tokens via CSS custom properties; light/dark via [data-theme] selector. Storybook-equivalent preview app + CSP-report-only Playwright gate.

Done (2026-05-30) — src/components/primitives.rs ports nine class-only Dioxus primitives from lib.jsx: Overline, GoldRule, StatusPill (10 kinds), Btn (6 kinds × 3 sizes + full), Mono, MonoChip, Card (soft/dense/glow + accent), Leaf (token-driven fills), and Icon (+ the 24-path icons set). Where lib.jsx maps a kind/size prop to an inline style string, canopy maps it to a CSS class (strict CSP — zero style: props). assets/canopy-portal.css gains the full primitive vocabulary; light/dark via [data-theme] is added as container-scoped [data-theme="light"]/[data-theme="dark"] token blocks (light shared with :root; the prefers-color-scheme media query scoped to :root:not([data-theme]) so MR3’s app-level toggle wins) — exercised today by the gallery’s two themed panels; the toggle control itself lands in MR3 (shell). The "Storybook-equivalent preview app" is pages::gallery::Gallery at the internal route /components (renders every primitive + icon in both themes). CSP gate re-ordering (devstack-aware): the planned live report-only Playwright gate has no served portal to target — MR1b removed canopy-portal from the devstack (dx-built, excluded from the musl image; the dx-served deploy image is deferred). For class-only components the strict-CSP "no inline styles" invariant is fully decided at server-render time, so MR2 gates it with an in-crate SSR render test (gallery_ssr_has_zero_inline_styles — renders the full gallery via dioxus_ssr::render, asserts zero style=/<style>); deterministic + devstack-free, runs in nextest. The live report-only Playwright gate + serving the portal in CI + stripping /components from production are re-ordered to the portal-serving (deploy) MR, tracked in #659. Verification: clippy --all-targets -D warnings clean both feature sets; 19 unit tests pass (15 prior + 4 new). No .rs/CSS inline styles; build-time CSP invariant proven by the SSR test.

3

feat(portal): responsive shell (shell.jsx → Rust) + useBoxWidth hook + nav primitives — Top bar + bottom tabs (<860 px) / side rail (≥860 px). Locale switcher + safety-exit affordance.

Done (2026-05-30) — src/components/shell.rs ports the responsive shell from shell.jsx: TopBar (brand + theme toggle, + safety-exit on the public/apply flow), SideRail (brand + nav + theme toggle), BottomTabs, the four-item NavItems (Home/Letters/Files/Help as Link`s with router-driven `active_class), and the ThemeToggle. The public + authed layouts render the shell; new route stubs Letters/Help (authed) + a real Safety screen (public, applicant-portal design ref §3.11 — DV hotline + helpline + "hide this page now", echoes no case data). Three scope decisions (consistent with the MR1c/MR2 "ship the control with its consumer" discipline): (1) useBoxWidth → CSS @media (min-width: 860px) — the prototype hook measured the resizable artboard box; in production the shell IS the viewport, so the native media query is the correct primitive (JS-free, no hydration flash, works pre-WASM; both rail and top-bar+tabs render, CSS shows the right one, scoped to .portal-shell—​authed). Full capability, not a trim. (2) Theme toggle built end-to-end (its consumer — MR2’s [data-theme] CSS — is ready): a Light/Dark/System cycle persisted to localStorage and applied via pure web-sys interop (src/interop.rs, set/remove data-theme on <html> — NOT document::eval, so no 'unsafe-eval'; ADR-026/#630). web-sys is a wasm32-only target dep with native no-op stubs, so the server build/SSR never call it. Improvement over the IMTN pattern: System removes data-theme so the prefers-color-scheme media query live-tracks the OS (vs snapshotting it). The signal is provided at the app root + restored in a client-only use_effect after hydration. (3) Locale switcher deferred to the i18n-consumer MR — it’s entangled with server locale negotiation + SSR <html lang> injection (i18n is server-only plumbing with no consumer yet); a switcher that sets a pref nothing reads is inert. Tracked in #660. Verification: native + wasm32 + server clippy --all-targets -D warnings clean; 23 unit tests (3 prior-MR + 4 prior + 3 theme-logic + the App-SSR shell strict-CSP gate); dx build --release bundles the web-sys interop.

4

feat(applications+common): credential generation + 3 migrations + argon2id + ID collision retry — TWO new tables (application_id_codes, passcode_hashes without table-level UNIQUE) + applications-table forward migration adding notify_email TEXT + notify_phone_e164 TEXT. argon2id hashing per ADR-019. 10-attempt retry-on-UniqueViolation in ID generator. Privacy-first redesign (ADR-026): both credential tables drop the REFERENCES applications(id) FK (reserved-id lifecycle — minted at draft-start, before the applications row exists); the generator mints the reserved application_id + code + passcode, consumed by MR6’s create-draft.

Done (2026-05-30) — crates/canopy-common/src/credentials.rs ships the credential primitives: generate_application_code() (HH-[a-f0-9]{8}, CSPRNG), generate_passcode() (12 digits drawn uniformly over 0..10^12NNNN-NNNN-NNNN, ADR-008 Amendment 3), normalize_passcode() (dash/whitespace-insensitive → 12 digits, so IVR-keypad / read-aloud entry verifies), and argon2id hash_passcode() / verify_passcode() (ADR-019; salt drawn from rand 0.9 + SaltString::encode_b64 to avoid the rand_core version mismatch between rand 0.9 and argon2 0.5). argon2 = "0.5" added to the workspace + canopy-common. The 3 forward migrations land the schema: application_id_codes + passcode_hashes (both reserved-id, no applications FK per ADR-026; passcode_hashes has NO table-level UNIQUE — only the partial WHERE revoked_at IS NULL unique index, rotation-friendly) + the applications notify_email / notify_phone_e164 columns. Antora data-models/canopy-applications.adoc updated (2 tables, 2 indexes, 3 migrations, the notify columns). Re-sequence (the "ship the control with its consumer" discipline used across MR1c–MR3): the store-layer mint + 10-attempt collision-retry moves to MR6 — canopy-applications integration tests are HTTP-only (no lib.rs / direct store import) and the retry-on-UniqueViolation needs the live DB + its caller, so a store fn here would be dead, untestable DB-mutation code; it lands with create-draft (MR6), which is its caller. 8 unit tests (format, leading-zeros, dash-insensitive round-trip, argon2id-salted, rejects wrong-passcode / garbage-hash); canopy-common clippy --all-targets -D warnings clean; migrations validated by the devstack service-startup sqlx::migrate! in pre-push.

5a

feat(applications+security): verify-credential + audit-ingest backend endpoints — the two service-side endpoints MR5b’s portal consumes, split out (the MR1c–MR4 "ship the control with its consumer" discipline) so each lands before its consumer and is independently testable without the portal / Redis / devstack changes. NEW POST /v1/applicants/verify-credential on canopy-applications (service-caller; verifies an HH-[a-f0-9]{8} code + 12-digit passcode against application_id_codes + the active passcode_hashes row; returns the reserved application_id; uniform 401 + timing-equalised so it is not a credential oracle). NEW best-effort POST /v1/security/audit/ingest on canopy-security (service-caller; mints the event_id + chains the row through the existing insert_audit_event, ADR-014) so the broker-less portal’s session events reach the audit log. No portal / Redis / devstack changes here.

Done (2026-05-30) — verify-credential (canopy-applications): store::credentials::verify_credential joins application_id_codes → the active (revoked_at IS NULL) passcode_hashes row and argon2id-verifies the dash-insensitive passcode (canopy_common::credentials::verify_passcode); an unknown code burns an equivalent verify against a throwaway hash so response time can’t enumerate codes; handler is require_service_caller + uniform ApiError::Unauthorized (401). audit/ingest (canopy-security): require_service_caller, server-mints the event_id, routes through the shared insert_audit_event so an HTTP-ingested event is chained identically to a broker-ingested one; 202. Contracts: canopy-contracts-applications::credentials (VerifyCredentialRequest/Response) + canopy-contracts-security::events::AuditEventIngestRequest + two path constants. Tests: 2 in-src EphemeralSchema store tests (match dash-insensitive / wrong / unknown + revoked-never-verifies + rotation-does) + HTTP negative-path (uniform-401) + audit round-trip (POST → 202 → household-filtered read-back, chained) integration tests. Antora api/canopy-applications.adoc + api/canopy-security.adoc updated. clippy --all-targets -D warnings clean.

5b

feat(portal): Welcome + Lookup + Submitted screens + /lookup + Redis session minting — Port entry-apply.jsx screens. POST /lookup in canopy-portal (outside canopy-api /v1 JWT-wrap); internally calls the MR5a POST /v1/applicants/verify-credential endpoint, then fire-and-forgets to the MR5a POST /v1/security/audit/ingest. Privacy-first redesign (ADR-026): mints the opaque session token into Redis (session:{token_hash} → ids), NOT a Postgres applicant_sessions table, with flow_kind-derived TTL (2 hr steady-state); /lookup is the resume / post-submission login path (re-mints the session; the initial session is minted at MR6’s create-draft). Hard prerequisite: the noeviction Redis session keyspace — resolved 2026-05-30 as a separate redis-sessions devstack container (maxmemory-policy noeviction, NOT a separate logical DB: Redis maxmemory-policy is instance-global, so a second DB on the existing allkeys-lru instance would still evict live sessions) + adding the redis (redis-rs) client to the root workspace deps. First infra-touching MR: also wires the portal’s ADR-019 service-token path (a canopy-portal Keycloak client + secret + ServiceTokenSource) so it can call the JWT-gated /v1 endpoint.

Done (2026-05-30) — Kept UNIFIED (not sub-split): the session store’s only non-test consumer is /lookup, and canopy-portal is bin-only, so a store-without-/lookup slice would be dead_code under -D warnings (the MR4 store-mint rationale) — infra + store + service-token + routes + screens ship together. Infra: new redis-sessions devstack container (maxmemory-policy noeviction, host 6380) + ("redis-sessions", 6379, 6380) in xtask PORT_MAPPINGS + redis = "1" (redis-rs, tokio-rustls-comp`connection-manager`) in the root workspace; the `canopy-portal` Keycloak service client + `dev-…-secret` *already existed* in `canopy-realm.json` (no realm/SOPS change; no port-reconcile re-entanglement). *Store* (`src/session.rs`): opaque 256-bit CSPRNG token (base64url), `session:{base64url(sha256(token))}` Redis key (raw token only in the cookie), `ApplicantSession{application_id,code,device_id,flow_kind,expires_at}` JSON value, `flow_kind` TTL (30 min apply/recovery/renewal · 2 h steady-state · 15 min kiosk), `mint`/`read`/`revoke` over `ConnectionManager`. *Routes* (`src/lookup.rs`, plain Axum, mounted before the Dioxus fallback, NOT under `/v1`): `POST /lookup` (service-token → verify-credential → mint → `HttpOnly`SameSite=Strict cookie → 303 /home + fire-and-forget applicant.session.minted audit; uniform ?error=invalid on 401, ?error=unavailable on outage — degrades, never breaks), GET /me (read → 200/401/503), POST /logout (revoke + clear cookie). Deps built best-effort at boot (build_lookup_deps); if Keycloak/redis-sessions are unreachable the routes simply aren’t mounted (the app + health probes still serve) — host-mapped CANOPY_PORT_* ports are the default since the portal runs on the host via dx serve, not a container (#659). Screens: Lookup renders a class-only <form method=post action=/lookup> (progressive-enhancement, works pre-WASM, strict-CSP-clean) with a generic non-enumerating error banner; ?:error query added to the route; form CSS added to canopy-portal.css. Tests: 12 new (6 session-store — 3 unit [flow_kind TTLs, token-hashed key hides the raw token, tokens distinct + url-safe] + 3 against the live redis-sessions [mint/read/revoke round-trip, unknown→none, kiosk TTL]; 6 lookup — 4 cookie unit + 2 axum-oneshot flow [/lookup→/me→/logout happy path + rejected→generic-error-no-cookie, with a mock canopy-applications]) — 35 portal tests total, both SSR strict-CSP gates still green (the new form adds zero inline styles). native+wasm32+server clippy -D warnings clean. Test boundary: a live end-to-end portal test (served + real Keycloak) belongs to the portal-serving infra (#659); the oneshot flow test + the MR5a endpoint tests cover the contract until then.

6a

feat(applications): application_drafts + create-draft / patch-draft backend — the canopy-applications backend of the incremental Apply flow, split out of MR6 the way MR5 split into 5a/5b (the "ship the control with its consumer" discipline): it lands and is independently HTTP-testable before the portal Apply form (MR6b) consumes it. NEW application_drafts table holding a client-side-encrypted draft keyed on the reserved application_id (no FK to applications; DDL per ADR-026). NEW service-caller POST /v1/applicants/drafts (create-draft) mints the reserved id + credentials via the MR4 canopy_common::credentials generators plus the store-layer mint + 10-attempt collision-retry re-sequenced here from MR4 (its DB write, with the live-DB retry-on-UniqueViolation) and returns {application_id, code, raw passcode, kdf_salt, enc_version}; NEW service-caller PATCH /v1/applicants/drafts/{id} (patch-draft) stores the per-step base64 ciphertext/nonce blind and slides the 30-day expiry. The portal-side initial Redis session mint (the portal owns Redis, not canopy-applications), the client crypto + Apply form UI, and finalize + the reaper + ele-consent are MR6b / MR6c.

Done (2026-05-30) — application_drafts migration 20260603000000 (reserved-id PK, no applications FK, sliding 30-day expires_at indexed for the reaper, current_step CHECK 1–4). store::drafts::mint_draft (bounded 10-attempt collision-retry on application_id_codes.code, one transaction across the three reserved-id tables, returns the raw passcode + non-secret kdf_salt) + patch_draft (blind ciphertext/nonce store + sliding expiry, Ok(false)→404). Handlers create_draft (201) + patch_draft (204/404/422 base64-or-step/403) both require_service_caller; base64 at the API boundary, raw bytes in the store. Contracts canopy-contracts-applications::drafts (CreateDraftResponse / PatchDraftRequest) + two path constants; OpenAPI count 15 → 17. 9 tests: 3 in-src EphemeralSchema store (mint writes all three rows + resolves via the MR5a verify path; patch replaces ciphertext + bumps the sliding expiry + misses unknown; consecutive-distinct) + 6 HTTP (mint→verify round-trip, patch + 404, malformed-base64 422, step-out-of-range 422, service-caller gate ×2). clippy --all-targets -D warnings clean; Antora data-models + api/canopy-applications.adoc updated.

6b

feat(portal): Apply 4-step form + client-side draft crypto + create-draft/patch proxy — the portal consumer of MR6a. Port the entry-apply.jsx 4-step Apply form into Dioxus; add the WASM client crypto (argon2(passcode, kdf_salt) → key → XChaCha20-Poly1305 per ADR-026 — no eval, no new CSP directive) that encrypts each step’s partial payload; add the portal /apply/* Axum proxy routes (reuse MR5b lookup.rs / build_lookup_deps / service-token) that call MR6a’s create-draft on start (returning the credential to the client) + patch-draft per step, and mint the initial FlowKind::Apply Redis session at create-draft (reuse session.rs). Keeps the SSR zero-inline-style strict-CSP gate green.

Done (2026-05-30) — crate::crypto: derive_key (argon2id hash_password_into, dash-insensitive, 256-bit), encrypt/decrypt (XChaCha20-Poly1305, new_from_slice + XNonce::from to avoid the deprecated from_slice), random_nonce (wasm32 = crypto.getRandomValues via web-sys Crypto; native stub). argon2 + chacha20poly1305 pulled default-features = falseno getrandom wasm backend needed (the nonce + salt are parameters, never generated inside the crate); de-risked by a clean cargo build --target wasm32-unknown-unknown. src/apply.rs: start_draft (service-token → create-draft → mint FlowKind::Apply session → cookie + credential JSON; passcode NOT stored in the session) + save_draft (session → reserved id → forward {ciphertext,nonce,enc_version,current_step} to patch-draft for THAT id; 204/401/404/422). src/client_api.rs: a CSP-clean web-sys fetch (wasm-bindgen-futures; connect-src 'self'; reqwest-free on the client). pages/apply.rs: the Dioxus wizard (Begin → 4 data steps → review; "Begin" derives the key, each "Continue" encrypts the accumulated form + saves the ciphertext) — class-only, with the wizard CSS in canopy-portal.css. base64 + serde/serde_json moved to core (client needs them). Scope: forward flow only — resume (get-draft) + finalize + the credential reveal are MR6c, so Submit is gated. 10 new tests (6 crypto round-trip/reject + 2 apply-oneshot + 2 SSR strict-CSP gates); 43 portal tests; native + wasm32 clippy --all-targets -D warnings clean; dx build --release zero inline styles.

6c

feat(applications): Apply-form finalize (materialise-at-finalize) + canopy-persons service-client — the finalize backend, split out of the planned "6c" (finalize + persons-client + reaper + ele-consent) into 6c (this) + 6d (reaper) + 6e (portal proxy + Submitted screen), the way 6 split into 6a/6b/6c. finalize (the client submits the in-memory plaintext — no server read-back needed in the forward flow) creates persons → household → members → income over canopy-persons + the applications row with the reserved id (a new explicit-id create path — today’s create always ApplicationId::new()`s) and DELETEs the draft in one canopy-applications transaction (`SELECT … FOR UPDATE on the draft, serialising with the reaper), keeping the credentials. canopy-applications gains its first outbound service-client (persons_client.rs, ADR-019 service token). Records ELE consent directly (emits application.ele_consent_recorded in-tx, conditional on ele_consent) rather than a self-HTTP-call.

Done (2026-05-31) — POST /v1/applicants/drafts/{id}/finalize (service-caller). persons_client.rs (reqwest + ServiceTokenSource.with_service_identity, per-call token) wired in main.rs from boot.service_token_source + new persons_url config; the canopy-applications Keycloak service-account client already existed (only the env creds + PERSONS_URL added to compose — no realm change). Built graceful: Option<PersonsClient> so the service still boots without creds (finalize 500s). Store: create_application_with_id (explicit reserved id) + lock_draft_for_update + delete_draft (draft only — credentials kept) + draft_exists (early 404 before persons writes) + update_notify_contact. Handler: validate programs → early existence check → persons orchestration (applicant + members + income, person_index-aligned) → tx{ FOR UPDATE lock (404 race) · insert app+programs · notify contact · application.submitted + optional ele_consent_recorded events · delete draft } → 201 {application_id, household_id}. Contracts canopy-contracts-applications::finalize; OpenAPI 17→18. 4 cross-service HTTP tests (materialise+keep-credentials+delete-draft / unknown-draft 404 / unknown-program 422 / service-caller 403). clippy --all-targets -D warnings clean. Note: the persons writes are NOT in the local tx (ADR-026 §5 cross-service window — a reaper that wins the race orphans persons; accepted).

6d

feat(applications): sliding 30-day draft reaper (scheduler) — a background job (the canopy-renewals / canopy-medicaid scheduler.rs + run_with_advisory_lock pattern, net-new to canopy-applications) that deletes application_drafts WHERE expires_at < now() and, in the same step, the matching application_id_codes + passcode_hashes rows (explicit ordered deletes — NOT cascade; FOR-UPDATE-serialised with finalize so it can’t reap a draft mid-finalize). An admin on-demand trigger endpoint makes the otherwise timer-driven sweep testable.

Done (2026-05-31) — store::drafts::reap_expired_drafts (one transaction: SELECT … FOR UPDATE SKIP LOCKED the expired drafts — the predicate WHERE expires_at < now() is the in-tx expiry re-check under the lock — then explicit ordered deletes passcode_hashesapplication_id_codesapplication_drafts per row, NOT cascade; returns ReapResult { drafts_reaped }). SKIP LOCKED is the finalize serialisation: a draft whose finalize holds the row is skipped this sweep (finalize keeps the credentials), and a draft the reaper locks first blocks a racing finalize until it — and its credentials — are gone, so the reaper never strands a submitted application’s login. NEW src/scheduler.rs (canopy-applications' first background scheduler): run_reaper_tick wraps the sweep in run_with_advisory_lock("canopy-applications.draft-reaper") (#428 leader-election); spawn_reaper_task runs it daily; wired in main.rs from boot.db.clone(). Admin trigger POST /v1/applicants/drafts/reap (service-caller only, ADR-019 — operator tooling, not applicant-reachable) returns ReapDraftsResponse { drafts_reaped }; route registered as a static sibling of …/drafts/{id} (matchit 0.8 prioritises the static segment; methods differ besides). Contracts canopy-contracts-applications::drafts::ReapDraftsResponse + paths::REAP_DRAFTS; OpenAPI 18 → 19. 4 tests: 1 in-src EphemeralSchema store (force-expire one of two drafts → only it + its credentials are reaped, the fresh draft’s login survives, a second sweep is a clean 0-no-op) + 1 scheduler leader-election regression (assert_lock_election_behavior) + 2 HTTP integration (force-expire over a direct pool then reap over HTTP → stale draft 404s + its credential 401s + the fresh draft 204s / verify-200s; service-caller gate 403). clippy --all-targets -D warnings clean.

6e

feat(portal): /apply/finalize proxy + Submitted credential-reveal screen — the portal consumer of MR6c. A POST /apply/finalize proxy route (reads the session → reserved id, maps the in-memory DraftDataFinalizeRequest, calls MR6c with the service token) + the Submitted screen that reveals the HH-… code + 12-digit passcode (the client holds them in memory from MR6b’s /apply/start) with the "save this" affordance. Wires the review-step Submit (gated in MR6b).

Done (2026-05-31) — apply::finalize_application (src/apply.rs): session → reserved id → forward the client’s FinalizeRequest to canopy-applications finalize with the portal service token; the id is injected from the server-trusted session, never the body (404/422 propagate; mounted at POST /apply/finalize). The wizard’s review Submit is wired (was gated): submit validates + builds the FinalizeRequest from the in-memory plaintext, POSTs, and advances to a terminal ApplySubmitted/CredentialReveal that shows the Application ID + passcode once — read from the in-memory DraftSession (now retains code + passcode from /apply/start), client-only, never SSR (step starts at 0 server-side); the reveal CTAs are full-page <a href> so leaving clears the passcode from WASM memory. To make the draft materialisable the wizard collects what finalize needs (no fabrication): split first/last name, a native <input type="date"> DOB (ISO → NaiveDate), and per-member name + DOB. Mapping: income empty (categories ≠ verifiable amounts; worker records later), programs_requested = ["snap"], ele_consent = false, notify_email carried, phone best-effort US E.164 or omitted. No OpenAPI change (the finalize endpoint is MR6c’s; canopy-portal has no JSON-API surface). 7 new tests (2 finalize-proxy flow over a mock + real redis-sessions + reveal SSR gate + submit-enabled gate + E.164 + mapping/validation); 50 portal tests; native + wasm32 + server clippy --all-targets -D warnings clean; dx build --release zero inline styles. Completes MR6 (the "6c"→6c/6d/6e split) — the full apply→submit demo flow is live.

7

feat(portal): Redis rate-limiting cascade + Turnstile gate — NEW services/canopy-portal/src/ratelimit/ per-device-cookie + per-CaseID + per-IP CGNAT-aware cascade. Same generic 401 error for all rejections (applicant-portal design ref §3.6). Turnstile invisible-mode CAPTCHA gate (or hCaptcha fallback; NEVER reCAPTCHA).

Done (2026-05-31) — the rate-limit cascade (src/ratelimit.rs). The CAPTCHA gate re-sequences to MR8, where its consumer (recovery completion) lands: a rate-limiter (always-on, cross-cutting middleware) and a CAPTCHA (step-up on one high-risk action) are different controls with different homes; building the verifier in MR7 with no consumer is dead code (the bin-crate -D warnings constraint that drove MR4→MR6), and bolting it onto /lookup would attach it to the wrong endpoint (lookup is already covered by the cascade + the uniform oracle-safe reject). RateLimiter enum (Redis | Disabled; fails open — a benefits portal must not deny applicants over a cache hiccup), over the noeviction redis-sessions keyspace (rl: namespace; an evictable counter is an attacker-evictable counter). Two mechanisms: (1) a from_fn middleware (route_layer-scoped to /lookup + /apply/) running the device-cookie (5/hr, 10/day) + IP (300/hr; 3000/hr on the RFC 6598 100.64.0.0/10 CGNAT block, config-extensible) tiers → a uniform 429 + Retry-After that never names the tier (applicant-portal design ref §3.6); it issues a 1-year HttpOnly`Strict` device cookie if absent (the cascade's primary key, NOT IP, so CGNAT neighbours don't punish each other). (2) the **per-CaseID brute-force cap** (8 wrong/day per `HH-…`) lives in the `/lookup` handler — a pre-request middleware can't know whether a credential verifies — checked before verify (reject == a wrong-passcode redirect, oracle-safe), recorded on a 401 miss, cleared on success; it caps total brute-force progress against one application even across cycled devices/IPs (the cookie-cycling backstop). Counters use an atomic `INCR`+first-hit-`EXPIRE`TTL Lua script (fixed window; no orphan-without-TTL on a crash). into_make_service_with_connect_info::<SocketAddr> feeds the IP tier; X-Forwarded-For is honoured only behind trust_forwarded_for. Config via CANOPY_PORTAL__RATELIMIT_ (no secrets — limits aren’t secret; the CAPTCHA verifier abstraction lands in MR8b with a noop default, and the real provider — preferred org mCaptcha PoW — is deferred to #663, so no provider/SOPS secret ships with the cascade or MR8b). The Apply client surfaces a friendly message on a 429. NEW workspace dep ipnetwork; redis gains the script feature. 10 rate-limit tests (CGNAT match, config defaults, device-cookie parse/hardening, positive_ttl, disabled-allows, device-tier-trips + case-cap vs live redis-sessions, + 2 middleware oneshot: device-cookie-issued→uniform-429+Retry-After, disabled-never-throttles); 60 portal tests; native + wasm32 + server clippy --all-targets -D warnings clean. (A live served-portal curl probe is part of #659 portal-serving; the oneshot-through-the-real-middleware + live-Redis tests are the in-process equivalent.)

8

feat(portal+applications+notices): Recovery flow + 24h pending state + side-channel notification + kill-switch + Turnstile gate + NEW scheduler module — NEW recovery_pending table. NEW endpoints POST /v1/applicants/recover/initiate + POST /v1/applicants/recover/kill/{token}. NEW canopy-notices subscriber emails+SMS to application-time contact (applicant-portal design ref §3.7). NEW services/canopy-applications/src/scheduler.rs (does not exist today; mirrors canopy-renewals pattern). The CAPTCHA verifier abstraction lands here with noop as the default; the real provider is deferred to #663 (re-decided 2026-05-31): MR8b ships the config-driven CANOPY_PORTAL__CAPTCHA_PROVIDER=…|noop seam gating /recover initiate completion, with noop shipping by default (the MR7 rate-limit cascade carries interim bot-deterrence). The preferred eventual provider is the org-hosted mCaptcha (PoW) enterprise service (accessible-by-construction + self-hosted — privacy for the intimate-threat flow), fallback hCaptcha/Turnstile, never reCAPTCHA; the CSP impact is provider-derived (each provider declares its origins; noop → strict CSP unchanged; self-hosted mCaptcha → 'self'/internal-origin at most; Turnstile → challenges.cloudflare.com) — so MR8b adds no Cloudflare CSP carve-out. The MR7 rate-limit middleware already covers the recovery endpoints (apply the existing route_layer). Split (refined at build): 8a = the canopy-applications recovery backend + the #634 confidentiality flag (the hard prerequisite — recovery without the confidential-case gate is a safety hole); 8b = the portal /recover wizard + the CAPTCHA verifier abstraction (noop default) + the per-session 2-wrong-answers RecoverLocked lockout (a session/wizard control, unbuilt by 8a) — the verifier lives here because its consumer is the public /recover completion (same "build the control where its consumer is" principle that re-sequenced the gate MR7→MR8); 8c = the notices subscriber + email/SMS templates + the 24h-gated reveal screen + the seeded confidential persona + E2E (closes #634).

Done (2026-05-31) — Closes #634 — MR8a done (2026-05-31): the canopy-applications recovery backend + the #634 confidentiality data model + gate. New recovery_pending table (24h pending + kill-switch token + notify_* snapshot + active-per-app partial unique index) + two recovery-gate columns on applications (confidentiality CHECK-set + recovery_locked boolean — NOT a status value). New canopy-reference::Confidentiality enum (disables_self_serve_recovery(): only Confidential/Both). New store::recovery (target_for_code submitted-only resolution; initiate idempotent-per-active; kill cancel+lock in one tx; prune) + PersonsClient::get_person (the DOB second factor). New service-caller endpoints POST /v1/applicants/recover/{initiate,kill/{token}}: initiate runs App-ID gate → confidential/locked short-circuit → DOB second factor and always returns 200 with the outcome in the body (pending/confidential_blocked/challenge_failed) so it is not an enumeration oracle (unknown-code == wrong-DOB; confidential_blocked is the §3.8-accepted disclosure). kill cancels + locks (404 unknown/terminal token). Three PII-allowlist outbox events (recovery_{initiated,killed,confidential_blocked}) staged in-tx (#477). New recovery-pruner daily leader-elected scheduler tick (alongside the MR6d reaper). OpenAPI 19 → 21. 11 tests (5 store EphemeralSchema + 1 scheduler election + 5 HTTP cross-service). Design deviations (ADR-013): (1) the kill-switch token is NOT in the recovery_initiated event — MR8c’s notification subscriber reads it (and the notify contact) from the recovery_pending row by recovery_id, keeping the kill capability off the broadcast bus + the security audit log (the plan’s ApplicantRecoveryInitiatedEvent sketch inlined it); (2) the kill-switch case-lock is the new applications.recovery_locked boolean (no recovery_locked status — it would pollute every status consumer); (3) the recent_letter_id / approx_decision_year §3.7 friction challenges are accepted in the contract but not yet server-verified (their cross-service verification against canopy-notices / the determination date is additive and tracked in #662 — the App-ID + DOB gate is the boundary per §3.7). MR8b done (2026-05-31): the portal /recover wizard + the CAPTCHA verifier abstraction shipping noop (real provider deferred #663; no CSP carve-out) + the proxy routes + the per-session 2-wrong RecoverLocked lockout + the safety exit (72 portal tests). MR8c-1 done (2026-05-31): the service-caller-only GET /v1/applicants/recover/{recovery_id}RecoverDetailResponse (contact + kill-switch token read from the row, off the event; OpenAPI 21 → 22). MR8c-2 done (2026-05-31): the canopy-notices recovery subscriber — a dedicated canopy-notices.recovery queue on application.applicant.recovery_initiated (NOT a manifest Typst notice — it is an email/SMS), a new ApplicationsClient (service-token read-back of MR8c-1), a RecoveryNotificationAdapter stub that logs the redacted contact + the full kill-switch link + the 24h reveal time (never the passcode), idempotent on the event_inbox; canopy-notices gains its OIDC service-account creds + APPLICATIONS_URL + PORTAL_BASE_URL; verified end-to-end against the live stack (recover/initiate → event → stub delivery with the kill link + redacted contact). MR8c-3 done (2026-05-31): the seeded confidential persona ("Dana Winters", HH-c0ffee42, confidentiality = 'confidential') added to the demo dataset via the generator (tools/canopy-seed/src/demo/sql_extras.rs — two appended BEGIN; … COMMIT; supplements mirroring render_wic_appointments_supplement; the argon2id passcode hash is precomputed to preserve byte-stable output; reserved-id credential tables [application_id_codes + an active passcode_hashes row] are truncated in the supplement + added to the --reset list since they carry no FK to applications per ADR-026) + a demo-profile-gated portal-recover Playwright project walking /recover against the served portal and asserting the ConfidentialBlocked helpline routing (no pending/notification leak) plus an oracle-uniform unknown-code path; the project is gated on CANOPY_E2E_SEED_PROFILE=demo (forwarded by xtask e2e) so the default e2e run is unaffected. Verified live end-to-end (both specs pass; the seed loads the persona cleanly and the household resolves cross-DB). Closes #634. MR8c-4 done (2026-05-31) — the recovery flow is COMPLETE: the 24h rotate-and-reveal (re-passing the App-ID + DOB challenge after reveal_at rotates the passcode — revoke the active passcode_hashes row + issue a fresh one + stamp completed_at, all one tx — and returns the new RecoverInitiateOutcome::Revealed{code, passcode} instead of re-minting; the argon2 hash runs only on the rare ripe path via find_ripe_pending’s cheap `SELECT … FOR UPDATE; the rotated passcode rides the HTTP body once and is otherwise only a hash, never on the IDs-only recovery_completed event) + the portal one-time reveal screen (reuses the apply apply-credential styling; a full-page anchor drops the passcode from WASM memory; never SSR’d) + the GET /recover/kill/{token} email-link landing (a Dioxus KillConfirm confirmation page — NO auto-cancel, since email clients prefetch GETs — whose explicit button POSTs to /recover/kill/{token}/confirm; the POST moved to the /confirm sub-path because a POST on the bare path shadows the GET landing → 405, the #659 /lookup route-collision lesson re-hit and caught by the live E2E). Store rotation test + HTTP integration test (initiate → backdate window → reveal with a 12-digit passcode that verifies via verify-credential → third pass mints a new pending = one-shot) + portal map_outcome/map_kill units + reveal/kill SSR strict-CSP gates + a served kill-landing E2E. Folded in: the canopy-portal Docker image moved glibc→musl/Alpine (the "dx unreliable on musl" claim was an untested bookworm-spike assumption; IMTN + the live Alpine build disprove it — 51 MB vs 163 MB, unified lineage, --debug-symbols false kept). OpenAPI unchanged at 22 (the Revealed variant is schema-only, no new path). CAPTCHA re-decision (2026-05-31, post-MR8a): MR8b ships the verifier abstraction with noop default; the real provider (preferred org mCaptcha PoW enterprise service, fallback hCaptcha/Turnstile, never reCAPTCHA) + any CSP carve-out are deferred to #663noop keeps the strict CSP fully intact, the rate-limit cascade carries interim deterrence.

9

feat(applications+web+store): document upload endpoint + application_documents table + AppState extension + 10 MiB body limit + case-detail-documents section replacement — NEW application_documents migration. NEW POST /v1/applications/{id}/documents multipart endpoint using canopy-store::validate_upload. AppState gains scanner + object_store. canopy-applications boot overrides ApiServerOpts.body_limit = 10 * 1024 * 1024 (default 2 MiB at crates/canopy-api/src/lib.rs:64). Per-program S3 prefix via key namespacing {program}/{application_id}/{sha256}. Replaces Plan 1 #562 stub at services/canopy-web/src/case_detail/sections/documents.rs.

Done (2026-05-31) — split into 9a (canopy-applications + canopy-store backend), 9b (canopy-web worker case-detail Documents section + accept/reject), 9c (canopy-portal applicant /documents upload UI — the demo §3:15 consumer the headline’s web-only service list omitted; the portal /documents page was a stub tagged MR9). All three merged. MR9a done (2026-05-31): new application_documents table (forward migration 20260605000000) + a service-caller multipart/form-data POST /v1/applications/{id}/documents through the zero-trust canopy-store::validate_upload pipeline (#435), object stored under {program}/{application_id}/{sha256}; companion GET …/documents (list), GET …/documents/{doc}/content (inline byte stream), POST …/documents/{doc}/{accept,reject} (worker review — mutually-exclusive, application-scoped). New documents contract module (DocumentType/DocumentKind/UploadSource/DocumentReviewStatus closed-set enums + ApplicationDocument + accept/reject bodies; the storage path is not exposed — clients fetch via the content endpoint). OpenAPI 22 → 26 paths. 10 tests (2 store EphemeralSchema + 4 contract enum round-trips + 4 HTTP integration: upload/list/content/accept/reject roundtrip, unknown-app 404, content-type-mismatch 422, auth-required 401). Design deviations (ADR-013): (1) object_store + scanner wire as one DocumentStorage Extension, not shared-AppState fields — the canopy-api::AppState is used by 15 services and holds only db`auth`; every cross-cutting dep here (`persons_client`/`publisher`/params) is an Extension, mirroring canopy-notices' Store wiring exactly. **(2)** The `assert_owns_application` / `X-Canopy-Actor` ownership check is **deferred** — ADR-026 made the applicant portal Postgres-free with opaque (non-JWT) sessions, so there is no applicant actor JWT to verify and canopy-applications configures no `ActorVerifier`; an unverified actor header would add no security over the path id. The real IDOR boundary is the BFF deriving `application_id` from the server-trusted session (the finalize MR6e pattern; built in MR9c). Endpoints are `require_service_caller()`-gated. Filed as #665 (a canopy-applications-layer defence-in-depth needs an applicant-token signer first). **(3)** `document_type`/`document_kind` are closed-set enums in the documents contract — `verifications.verification_type` is an unconstrained `TEXT`/`String`, so there is no shared enum to import; the migration CHECK defines the set. **(4)** Body limit is `max(configured, 11 MiB)` = 10 MiB per-file (the `validate_upload` ceiling) + 1 MiB multipart-framing headroom, so a genuine 10 MiB file isn't rejected by `DefaultBodyLimit` before validation runs. **MR9b done (2026-05-31):** the canopy-web worker case-detail Documents section replaces the #562 coming-soon stub — `documents::fetch` now lists `GET /v1/applications/{id}/documents` (the section's `dispatch_fetch` arm passes the full `clients`/`application_id`/`household_id`/`csrf_token` context; empty `application_id` → "no application" empty state) into a typed table (type/size/uploaded/status pill + a pending-count badge) with per-pending-row Accept (single-button form) + Reject (`<details>`-gated reason form) affordances; two action handlers (`POST /actions/documents/{accept,reject}`) forward to MR9a's accept/reject with the worker's service identity (`accepted_by` = worker Keycloak sub when UUID-parseable) + post-redirect-get to the Documents section; a content-proxy (`GET /documents/{application_id}/{document_id}/content`, behind `require_auth`) streams the bytes back with the upstream content-type (inline) via a new `InternalClient::get_raw_typed`; `Plugin.toml` corrected (`source = canopy-applications`). 5 section unit tests (URL builder, byte-size scaling, status-pill/label/content-path projection across pending/accepted/rejected); the live render is exercised by the existing case-detail E2E (accessibility + demo-review specs render every section). **MR9c done (2026-05-31) — MR9 COMPLETE:** the canopy-portal applicant `/documents` page replaces its stub with a working upload + an applicant-visible status list. Upload is a **native** `multipart/form-data` form (no WASM file-reading — progressive enhancement under the strict CSP `form-action 'self'`, the same native-form mechanism `/lookup` already uses) → `POST /documents/upload` proxy which derives `application_id` from the server-trusted **session** (the IDOR boundary; never client input — the control canopy-applications can't enforce under ADR-026/#665), resolves the applicant's `person_id` from their application (`GET /v1/applications/{id}` — note `ApplicationWithPrograms` `#[serde(flatten)]`s the Application, so `submitted_by` is TOP-LEVEL; a bug the LIVE smoke caught because the unit mock had encoded the same wrong nesting — the differential-framing trap), re-forwards with the service token, and post-redirect-gets back with a `?uploaded=1`/`?error=<reason>` banner. The status list is client-fetched after hydration (`GET /documents/list` read-through + a `use_effect`spawn on-mount fetch via a new client_api::get_text) showing filename/type + a review-status pill. axum added as a portal server-dep for the Multipart extractor; 11 MiB body limit on the upload route. 5 portal tests (2 page-projection + 3 proxy flow incl. an IDOR-boundary upload that 404s if mis-routed) + verified LIVE end-to-end against the served portal + demo persona (login via native form → upload → redirect → list shows the doc as "Pending review"). The full MR9 vertical (applicant upload → worker review) is demoable. MR10 (scripted IEVS/SAVE) + MR11 (seed + Playwright walk + bundle gate) remain.

10

feat(verification+web): scripted IEVS/SAVE adapters + verification_responses table + application_id filter + applicant inbox + case-detail-verifications stub replacement — NEW ScriptedIevsAdapter + ScriptedSaveAdapter at services/canopy-verification/src/scripted.rs (alongside NoopIevsAdapter at noop.rs:20-271). NEW devstack/fixtures/{ievs,save}-scripts.toml keyed by household_id. NEW verification_responses table. NEW application_id query-param filter on GET /v1/verifications (de-conflict with Plan 2 MR2). NEW POST /v1/verifications/{id}/respond accepting Vec<DocumentId>. Replaces Plan 1 #562 stub at services/canopy-web/src/case_detail/sections/verifications.rs.

Done (2026-06-01) — split into 10a (canopy-verification scripted IEVS/SAVE adapters + fixtures + config), 10b (verification_responses table + POST /v1/verifications/{id}/respond; the application_id filter already shipped via Plan 2 MR2), 10c (worker case-detail Verifications section + applicant /verifications inbox). MR10a done (2026-06-01): new ScriptedIevsAdapter + ScriptedSaveAdapter (services/canopy-verification/src/scripted.rs) alongside the SSN-suffix NoopIevsAdapter; new devstack/fixtures/{ievs,save}-scripts.toml; runtime selection via CANOPY_VERIFICATION__{IEVS,SAVE}_ADAPTER (default noop — devstack/UAT/prod unchanged; the demo flip to scripted lands with MR11’s personas). 10 unit tests; Dana Winters (HH-c0ffee42, CONF_PERSON_ID) supplies the MR10a mechanism-proof IEVS fixture entry. Design deviation (ADR-013): the plan keys fixtures by household_id, but SaveVerifyHttpRequest carries no household_id (only IevsMatchHttpRequest does) — so the fixtures key by person_id, the one identifier available to both the IEVS and SAVE handlers and the correct per-person granularity. Both adapter traits gained a person_id parameter (threaded from the handlers); the Noop* adapters ignore it. Runtime dispatch is via IevsAdapterKind / SaveAdapterKind enums (the traits use RPITIT → not dyn-compatible). Unknown person_ids → empty IEVS match; SAVE errors loudly. MR10b done (2026-06-01): new verification_responses table (forward migration 20260601000000) + POST /v1/verifications/{id}/respond (writes one row per attached DocumentId + an optional text-only row) + GET /v1/verifications/{id}/responses read-back. New verification_responses contract module (RespondVerificationRequest/VerificationResponse/RespondedBySource). Ownership boundary: the handler asserts the caller’s application_id matches the verification’s own scope (403 on mismatch, 422 on unscoped/empty, 404 on unknown). document_id is a stored-not-validated cross-service ref to application_documents.id (#665-class boundary — the worker’s content fetch is application-scoped). OpenAPI 3 → 5 paths; 7 tests. The plan’s MR10(a) application_id filter already shipped via Plan 2 MR2. MR10c split into 10c-worker + 10c-portal. MR10c-worker done (2026-06-01): the canopy-web case-detail Verifications section replaces the #562 stub (services/canopy-web/src/case_detail/sections/verifications.rs) — lists the application’s open verifications (GET /v1/verifications?application_id=&status=pending) + per-item responses (GET /v1/verifications/{id}/responses) as document View links (via the MR9b content proxy) + text notes, with a derived Awaiting-vs-Responded status pill + a "to review" badge. Read-only (the list endpoint is pending-only Phase 1; the worker acts in the Documents section). 5 unit tests; live render exercised by the case-detail E2E. MR10c-portal done (2026-06-01) — MR10 COMPLETE: the canopy-portal /verifications page replaces its stub with the respond flow (demo §3:45) — client-fetches a combined GET /verifications/list (open verifications + the applicant’s documents, projected) and renders a native respond form per verification (single-document <select> + optional note) posting to POST /verifications/{id}/respond, which derives application_id+person_id from the session (the IDOR boundary, MR9c pattern) and re-forwards to MR10b as applicant_portal, post-redirect-getting ?responded=1/?error=. New verification_url portal dep (config + compose); a demo "Action needed" Home card links to the inbox (§2:45; MR11 expands Home). 12 portal tests (8 unit + 4 flow: IDOR 401 gate, combined-inbox fetch, respond-forwards-and-redirects, empty-guard). The full verification-response vertical (applicant attaches uploaded docs → worker sees them in the case-detail Verifications section) is demoable.

11

chore(seed+portal+web): Home screen + 6 case-state heroes + Playwright E2E + canopy-seed credentials seed + axe-core + es locale catalogs + demo runbook — Port HANDOFF home.jsx; 6 case-state heroes. canopy-seed demo profile extended with application_id_codes + passcode_hashes for Maria/Carlos/Tanya (deterministic codes; visible in demo runbook). axe-core in Playwright applicant-portal.spec.ts. Consolidated Spanish (es) Fluent translations. NEW docs/modules/ROOT/pages/runbooks/demo-applicant-credentials.adoc.

Done (2026-06-01) — split into 11a (demo personas + credentials runbook + scripted-IEVS devstack flip), 11b (Home six case-state heroes + es locale; further split into 11b-1 heroes + 11b-2 body sections + es), 11c (Playwright applicant-portal.spec.ts walk + axe-core + WASM bundle-size gate). MR11c done (2026-06-01) — MR11 + Plan 3 COMPLETE: a demo-profile-gated applicant-portal Playwright project (tests/e2e/specs/applicant-portal.spec.ts) walks the full journey against the served stack — new applicant Apply→Submit→credential reveal (real HH-…passcode asserted), then Maria `/lookup`→Home "Action needed" hero→`/documents` upload→`/verifications` respond, with a *separate caseworker context* confirming both the upload + the response in the worker case-detail Documents + Verifications sections (cross-service closure). Each applicant page gets axe-core WCAG 2.1 A+AA (zero violations) + CSP-clean + `<html lang>` assertions; a third test enforces a gzipped WASM bundle budget (≤2 MB initial / ≤5 MB per-route; served bundle ≈399 KB). Three real defects fixed in-MR: **(1)** the dx SSR shell never set `<html lang>` (WCAG SC 3.1.1) — now injected `lang="en"` in the CSP nonce rewrite layer (`csp.rs`, 4 tests); **(2)** `--portal-text-dim` (`#8ba095`, 2.78:1) failed WCAG SC 1.4.3 on the autosave-note/placeholders/recover-hint — darkened to `#637769` (4.8:1); **(3)** the per-device rate-limit cap (5/hr) was *below* one applicant's autosave write-count (`/apply/start` + per-step `/apply/save` + `/apply/finalize` ≈ 6 writes), so the finalize press 429'd a *legitimate* applicant — raised to 60/hr · 200/day (IP 300/hr + per-`HH-…` 8/day caps unchanged). *Deviation (ADR-013):* the worker-closure asserts via `?focus_section=` rather than a tab-click — the tabs-shell `get_tab` dispatch lacks arms for the newer composition sections (verifications/documents/audit/…), filed as #667. The walk's worker-side is proven via the working inline-render path. **MR11b-2 done (2026-06-01):** per-state *informational* body sections below each hero — "What happens next" timeline (`pending`/`active`), interview tips (`interview`), discover teaser (`approved`), reapply guidance (`closed`); a tested `body_kind` dispatch. The design's data-rich sections (program cards with benefit amounts, dated timelines, year recap) are deferred to issue #666 rather than shipped with fabricated per-applicant numbers; the es Fluent catalog stays deferred with portal Fluent consumption (no page consumes Fluent yet — post-UAT per ADR-008). Live-verified per persona, zero CSP violations. **MR11b-1 done (2026-06-01):** the authed `/home` page replaces its stub with the six per-case-state heroes (`pending`/`interview`/`approved`/`active`/`renewal`/`closed`) + a personalised greeting + a quick-links grid. A new session-gated `GET /home/state` proxy derives the state from the application status + `interview_required` + the open-verification count (application_id from the session — the IDOR boundary), projecting `{state, first_name, pending_count}`; the page client-fetches it post-hydration (`use_effect`spawn). The demo’s Maria lands on the active "Action needed" hero with her live count. Class-only styling (strict CSP); the design’s inline styles become --portal--token CSS classes; the MR10c .portal-action-card stub is removed. New persons_url portal dep (for the greeting name). 10 portal tests; live-verified (all three personas derive correctly; Maria’s hydrated Home renders the active hero with zero CSP violations). The per-state body sections + es Fluent catalog follow in 11b-2. MR11a done (2026-06-01): three fixed-UUID, login-capable personas in the demo seed profile — Maria Delgado (HH-c8841a23), Carlos Reyes (HH-ca7105ab), Tanya Brooks (HH-7a09aab0) — mirroring the confidential-persona supplement pattern (household/person/membership/address → canopy_persons.sql; application + program + Application ID code + active passcode hash → canopy_applications.sql; distinct 018ce0c1/2/3-… UUID block). Each argon2id passcode_hash is precomputed + re-verified in a test; cleartext passcodes never reach the SQL. Maria (the headline persona, es locale) additionally carries four pending verifications + an unreported-wage IEVS discrepancy in canopy_verification.sql (the §2:45–4:00 work items); Carlos (approved) + Tanya (interview-required) illustrate the other Home case-state heroes. *Deviation from the plan’s "scripted-IEVS devstack flip": the IEVS discrepancy is seeded at rest (an ievs_hits row, the archetype mechanism) rather than by globally flipping canopy-verification to the scripted adapter — the canopy-verification integration tests (internal_ievs_match_returns_noop_data, ievs_match_persists_hits_visible_in_discrepancies) assert Noop behaviour against the live devstack, so a global flip breaks them; the at-rest row is adapter-independent and the live scripted-IEVS demo is a documented opt-in. NEW runbooks/demo-applicant-credentials.adoc. 8 canopy-seed tests; live-verified end-to-end (dataset loads, Maria logs in, inbox surfaces all four verifications).

Sister plans: Plan 1 (worker intake + program independence — DONE 2026-05-27 MR !387) and Plan 2 (ELE 1-year-flag expansion — ele-1-year-flag-extension.adoc, in this combined commit).
Meta-plan handoff: ~/.claude/projects/-home-bitskrieg-code-canopy/memory/project_demo_video_3plan_handoff.md.
Design source-of-truth: design/canopy-portal/HANDOFF.md (committed MR !388; 1091 lines + 9,256 lines of .jsx reference prototypes).
Branch: feat/applicant-intake-and-verification (epic) with per-MR feature branches.
Labels: priority::high, program::snap, program::tanf, program::medicaid, program::infrastructure, service::portal, service::applications, service::verification, service::notices, service::web, service::shared-crates, service::devstack, type::feature, compliance::wcag-21-aa, workflow::ready.
Tracking issue: #630 ADR-008 §214 supersession.
Plan 2 dependency: Plan 2 MR3 ships POST /v1/applications/{id}/ele-consent; Plan 3 MR6 depends on it.

Context

The demo-video epic requires a true end-to-end applicant flow: applicant submits SNAP + TANF via canopy-portal, uploads documents, responds to verification requests, and watches their case transition through the worker side. The demo cannot be filmed without a real applicant-facing surface — pre-seeded fixtures alone don’t tell the story.

canopy-portal today is a session-only Axum stub (services/canopy-portal/src/main.rs:21-95) with Fluent i18n wired (en + es bundles) and PostgreSQL-backed sessions, but ZERO Dioxus, ZERO domain routes, ZERO applicant flow. The design team shipped a comprehensive 1091-line HANDOFF.md + 9,256 lines of .jsx reference prototypes (committed to design/canopy-portal/ in MR !388). Plan 3 implements the design.

This plan went through 6 reviewer iterations (4 internal contextless-subagent passes + 2 user-external review passes). The user-external review at v4 surfaced 8 blockers + 3 P1s that 4 prior internal reviewers missed — pattern matches Plans 1+2: internal reviewers trust pseudocode at the auth/RBAC/persistence boundaries; user catches them. v6 fixes all 11 findings + the v4 reviewer’s residual passcode_hashes UNIQUE DDL/prose contradiction.

User-locked design decisions (2026-05-27):

  • No accounts/passwords. Credential pair = application_id (HH-[a-f0-9]{8}) + passcode (NNNN-NNNN-NNNN, twelve uniform-random digits). NEVER DOB (too widely leaked).

  • Dioxus 0.7+ fullstack per ADR-008 (SSR + WASM hydration); first Dioxus introduction in the workspace.

  • Demo-critical subset: Phases 1-3 + Phase 4 minimum from HANDOFF.md §11. Phases 5-8 post-Plan-3.

  • Lost-credential recovery IS IN Plan 3 scope (applicant-portal design ref §3.4-3.8 — load-bearing for intimate-threat protection).

  • Rate-limiting cascade in Redis (existing devstack).

  • HANDOFF wins format conflict over ADR-008; Plan 3 MR1a files ADR-008 amendment.

  • Strict CSP: wasm-unsafe-eval is the ONLY unsafe directive allowed. ADR-008 §214 (which permits 'unsafe-inline' for styles) is amended in MR1a. Tracked separately at #630.

This plan does NOT own the worker portal (Plan 1) or the ELE 1-year flag (Plan 2). Plan 2 MR3 must land before Plan 3 MR6.

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

Reference number format

HANDOFF wins: HH-[a-f0-9]{8} Application ID + NNNN-NNNN-NNNN Passcode (twelve uniform-random digits). MR1a files ADR-008 amendment declaring CANOPY-YYYYMMDD-XXXXXXXX + DOB superseded.

CSP strictness

wasm-unsafe-eval is the ONLY unsafe directive allowed. No 'unsafe-inline' for styles, no 'unsafe-eval'. ADR-008 §214 amended in MR1a (per #630). Dioxus 0.7+ configured for class-only styling; every component renders against services/canopy-portal/assets/canopy-portal.css.

Public endpoint hosting

canopy-api::ApiServer::router at crates/canopy-api/src/lib.rs:127 wraps every /v1 route in JWT auth. Applicant lookup + recovery + apply + documents + verifications routes live in canopy-portal’s own Axum router OUTSIDE canopy-api. Internal canopy-portal → canopy-applications calls go through canopy-api authed via service-token.

ApplicantSessionToken shape

Privacy-first redesign — ADR-026: Redis-primary, the portal is Postgres-free. Opaque token, NOT a JWT (canopy-auth has no signer for custom-issuer JWTs). 256-bit CSPRNG token; Redis holds session:{token_hash}{application_id, code, device_id, flow_kind, expires_at} (token hashed at rest; raw token only in the cookie). TTL is flow_kind-derived; DEL is the server-side kill-switch; a device:{id} secondary index drives the rate-limit cascade. Minted at draft-start and tied to the reserved application_id. There is no anonymous PostgreSQL session — pre-draft browsing is stateless. Hard prerequisite (MR5): a noeviction Redis session keyspace (separate logical DB / instance with maxmemory-policy noeviction) — the devstack allkeys-lru cache config would silently evict live sessions. Cross-service calls use service-token + X-Canopy-Actor: applicant:<application_id_uuid> per ADR-019. (ADR-009 amended for the applicant portal only; the worker portal stays PostgreSQL-primary.)

RBAC / IDOR protection

Claims::require_applicant() checks role only. NEW applicant-ownership middleware in canopy-portal asserts session-token’s application_id matches path application_id. NEW assert_owns_application(claims, path_application_id) helper in canopy-auth for canopy-applications endpoints called via X-Canopy-Actor. Applies to /v1/applications/{id}/documents/\*, /v1/verifications/{id}/respond, /v1/applications/{id}/ele-consent.

Session TTL

30 min in apply/recovery/renewal; 2 hr in steady-state; 15 min in kiosk mode (per applicant-portal design ref §3.3). Driven by flow_kind column in session row.

Credential storage

Privacy-first redesign — ADR-026. The credential is minted server-side at draft-start (Model S), keyed on the reserved application_id; the passcode does double duty — source of the client-side Argon2id draft-encryption key and the post-submission login — and is revealed at submit or on explicit "continue later." application_id_codes (UNIQUE on application_id AND code) + passcode_hashes without table-level UNIQUE on application_id (would block rotation; active uniqueness via partial unique index WHERE revoked_at IS NULL). Neither table FKs to applications (reserved-id lifecycle — minted before the row exists). argon2id-hashed passcodes per ADR-019. Passcode is NNNN-NNNN-NNNN — one uniform CSPRNG draw over 0..10^12 (leading zeros valid), no wordlist (ADR-008 Amendment 3).

ID collision retry

32-bit hex codes have birthday collisions around 65K applications. MR4 generator wraps INSERT in bounded retry loop (10 attempts) on UniqueViolation.

Applications-table extension

Plan 3 MR4 adds forward migration extending applications with notify_email TEXT + notify_phone_e164 TEXT (NULLABLE). Apply form captures, Recovery consumes. ADR-016 additive only.

Draft persistence

Privacy-first redesign — ADR-026. NEW application_drafts table holding a client-side-encrypted JSONB draft (the server stores ciphertext it cannot bulk-read). Reserved-id lifecycle: keyed on the reserved application_id with no FK to applications (no row exists until finalize). DDL: application_drafts(application_id UUID PRIMARY KEY, kdf_salt BYTEA NOT NULL, ciphertext BYTEA NOT NULL, nonce BYTEA NOT NULL, enc_version SMALLINT NOT NULL, current_step INT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_saved_at TIMESTAMPTZ NOT NULL DEFAULT now(), expires_at TIMESTAMPTZ NOT NULL) + index on expires_at. expires_at is the sliding deadline last_saved_at + 30 days, rewritten on every save; the reaper deletes WHERE expires_at < now() and the matching reserved credentials (explicit ordered deletes, NOT ON DELETE CASCADE — finalize must keep credentials). kdf_salt is the per-draft Argon2id salt (non-secret, persisted for cross-session resume). No draft application status (drafts never enter applications).

Apply form orchestration

Privacy-first redesign — ADR-026. Incremental, not single-shot: create-draft (mint the reserved application_id + code + passcode + the empty draft row) → patch-draft (PATCH the client-encrypted ciphertext per step) → finalize. CreateApplicationRequest (crates/canopy-contracts-applications/src/applications.rs:62) requires household_id + submitted_by — the applicant has neither until finalize. finalize creates persons → household → members → income and the applications row with id = the reserved application_id (the existing create path always ApplicationId::new()`s — `store/mod.rs — so finalize needs an explicit-id create path), in one canopy-applications transaction with the application_drafts DELETE (the no-orphan guarantee), then keeps the credentials. Replaces the prior POST /v1/applications/from-applicant-submission single-atomic endpoint. canopy-applications adds canopy-persons service-client wiring as the MR6 deliverable.

Document upload

NEW POST /v1/applications/{id}/documents (multipart) on canopy-applications. AppState gains scanner + object_store. canopy-applications boot overrides ApiServerOpts.body_limit = 10 * 1024 * 1024 (default 2 MiB at crates/canopy-api/src/lib.rs:64). Per-program S3 prefix via key namespacing {program}/{application_id}/{sha256} (NOT per-program bucket; canopy-store has a single Store today). canopy-store::validate_upload handles size/mime/sha256/scan/sanitize.

Verification surface extension

MR10 adds: (a) application_id query-param filter on GET /v1/verifications (de-conflict with Plan 2 MR2 first-to-merge-wins). (b) NEW verification_responses table. (c) POST /v1/verifications/{id}/respond accepting Vec<DocumentId> references.

IEVS/SAVE scripted adapters

TOML configs at devstack/fixtures/{ievs,save}-scripts.toml (MR10 creates the devstack/fixtures/ directory — doesn’t yet exist). Keyed by household_id. NEW ScriptedIevsAdapter + ScriptedSaveAdapter at services/canopy-verification/src/scripted.rs alongside existing NoopIevsAdapter at noop.rs:20-271. Config-driven via CANOPY_VERIFICATION__IEVS_ADAPTER=scripted.

Rate-limiting cascade

Redis-backed (existing devstack — Redis service at repo-root docker-compose.yml around lines 77-90). NEW canopy-portal-ratelimit Redis namespace. Per-device-cookie (60/hr, 200/day — raised from the original 5/hr·10/day in MR11c, which was below one applicant’s per-step-autosave write-count and 429’d a legitimate finalize) + per-CaseID (8 wrong/day) + per-IP fallback (300/hr default; 3000/hr on CGNAT subnets). Same generic 401 for all rejections (applicant-portal design ref §3.6).

CAPTCHA provider

Re-decided 2026-05-31 — MR8b ships noop; real provider deferred to #663. MR8b ships only the config-driven verifier abstraction (CANOPY_PORTAL__CAPTCHA_PROVIDER=…|noop) with noop as the default; the rate-limit cascade carries interim bot-deterrence. Preferred eventual provider = the org-hosted mCaptcha (proof-of-work) enterprise service (serving canopy + other internal apps) — accessible-by-construction (invisible PoW, no image/typing — unlike a text-image CAPTCHA’s WCAG fail or Turnstile’s widget) and self-hosted (nothing about a recovery attempt leaves org infra — privacy for the DV/intimate-threat flow). Fallback: hCaptcha or Turnstile (config-selectable). Banned: Google reCAPTCHA (deanonymization). The CSP impact is provider-derived (each provider declares its CSP origins; the /recover route layer computes the carve-out): noop → strict CSP unchanged; self-hosted mCaptcha → at most an internal-origin entry, plausibly 'self'/none; Turnstile → https://challenges.cloudflare.com. Gate sits on /recover initiate completion (the automated-guessing threat is at initiate, not the time-gated reveal). See #663.

Lost-credential recovery threat model

Per applicant-portal design ref §3.7. Required entry (corrected 2026-05-31 to match §3.7 + the shipped MR8a contract): Application ID + date of birth. The App-ID is the gate (random hex an intimate threat is unlikely to have memorised); DOB is the second factor verified against the submitter’s canopy-persons record (§3.7 rejects a DOB-only path — an intimate threat knows the DOB — but DOB behind the App-ID gate is the designated second factor, NOT excluded). The most-recent-letter NTC number + rough decision year are additional friction challenges (§3.7) — accepted by the contract but not yet server-verified (tracked in #662); they are NOT the required entry. 2 wrong answers → RecoverLocked per-session + phone-only (a portal-wizard/session control, MR8b — MR8a ships no answer-counting). On challenges pass → 24h pending state; passcode generated fresh + held; side-channel notification (email+SMS via canopy-notices) to application-time contact (NOT recently-changed contact). One-tap kill-switch in notification. Confidential cases: self-serve disabled entirely (applications.confidentiality ∈ {confidential, both}); phone-only via 1-877-423-4746.

ADR-025 cross-service refs

Registered in crates/canopy-validators/src/lib.rs: application_documents.application_id → applications.id, application_documents.person_id → persons.persons.id, recovery_pending.application_id → applications.id. Reserved-id exception (ADR-026): application_drafts, application_id_codes, and passcode_hashes key on the reserved application_id with no hard applications FK (the row doesn’t exist until finalize) — a logical ref, not a DB FK. The cargo xtask demo verify orphan check for these tables must be conditional: a reserved id present in application_drafts is legitimately absent from applications and is NOT an orphan — flag only reserved ids in neither table. All four tables share the canopy_applications DB, so the carve-out is one demo.rs CHECKS entry with a UNION target (SELECT id::text FROM applications UNION SELECT application_id::text FROM application_drafts). The runtime HTTP validate_application is never called against an in-flight reserved id (validation is worker-side; workers never see drafts).

i18n catalogs

Fluent at services/canopy-portal/locales/{en,es}/{common,apply,status,notices,appeals,recover}.ftl per ADR-008 §150-164. en authored per-feature MR; es consolidated in MR11 with native-speaker review.

Accessibility (WCAG 2.1 AA)

Per ADR-008 §181-214 + applicant-portal design ref §10. axe-core integration in MR11 Playwright applicant-portal.spec.ts. Tap targets ≥44×44 px enforced via CSS in MR1c. prefers-reduced-motion + prefers-color-scheme via CSS media queries.

Secrets ownership

Redis URL credentials + the CANOPY_ENCRYPTION_KEY-class secrets live in SOPS-encrypted secrets/dev.yaml per ADR-017. NOT plain config. No CAPTCHA-provider secret ships in MR8b (the noop default has none); a provider site_key/secret_key (if the eventual provider needs one) is added to SOPS at integration time per #663 — ADR-017’s secret inventory does not currently enumerate a CAPTCHA secret. (ADR-026: applicant sessions are opaque random tokens hashed at rest — there is no session *signing key to manage.)*

Shared-deliverable de-conflict

DocumentId newtype (Plan 1 MR1 deliverable) + require_service_or_applicant_or_caseworker_or_above() Claims guard (Plan 2 MR3 deliverable) are first-to-merge-wins. Plan 3 MR1b checks git log origin/main at branch-off + declares or omits each.

MR count

11 MRs (MR1 split into 1a + 1b + 1c; MR5 split into 5a + 5b; MR6 split into 6a + 6b + 6c, and "6c" further into 6c + 6d + 6e). Ordering: MR1a → MR1b → MR1c → MR2 → MR3 → MR4 → MR5a → MR5b → MR6a → MR6b → MR6c → MR6d → MR6e → MR7 → MR8 → MR9 → MR10 → MR11. Plan 2 MR3 must land before Plan 3 MR6c (the ele-consent consumer). Execution model ratified 2026-05-28: Plans 2 + 3 run sequentially (Plan 2 fully, then Plan 3), so this dependency is guaranteed by construction — the ordering note is the guard; no CI/branch machinery required.

Open decisions

Resolved 2026-05-28 (post-implementation-readiness re-verification — see the demo readiness assessment):

  • Session-store shape — RESOLVED: a dedicated applicant_sessions table (see Locked decisions + the MR1b migration), NOT the tower-sessions default schema. Sessions stay PostgreSQL-backed per ADR-009 (cookie-only was rejected there).

  • Session-store substrate (re-resolved 2026-05-29, ADR-026) — supersedes the bullet above: Redis-primary, no anonymous PostgreSQL session, the portal is Postgres-free. The applicant_sessions PostgreSQL table is never created; sessions become opaque Redis tokens (session:{token_hash}) minted at draft-start. ADR-009 is amended for the applicant portal only.

  • Redis substrate + noeviction-keyspace shape (RESOLVED 2026-05-30, MR5a/MR5b boundary) — the noeviction session keyspace is a separate redis-sessions devstack container (maxmemory-policy noeviction, appendonly yes), NOT a separate logical DB on the existing redis instance: Redis maxmemory-policy is instance-global, so a second DB on the allkeys-lru cache instance would still evict live sessions — the "separate logical DB" option floated earlier is not actually viable. The cache instance keeps allkeys-lru; the two eviction policies stay isolated. Client = redis (redis-rs) with redis::aio::ConnectionManager (mature, conventional, light for a hand-rolled SET EX / GET / DEL token store), NOT fred. Both land in MR5b.

  • document_type vs DocumentKind — RESOLVED: orthogonal axes. document_type is the purpose (the SAME closed set as canopy-verification’s verification_type, which owns it per ADR-001); document_kind is the optional form metadata (HANDOFF DocumentKind). No third parallel enum — see the application_documents schema.

  • Execution model — RESOLVED: sequential (above).

  • Confidentiality flag (applicant-portal design ref §3.8) — DEFERRED to #634: recovery + confidential-case flows are out of the 10-min demo cut; the confidentiality column + enum + a seeded confidential persona land before the recovery flow (Plan 3 MR8) ships.

Still open — decided at the named MR:

  • CSP inline-style fallbackRESOLVED (MR1b, 2026-05-29): the spike PASSED on Dioxus 0.7.9. The release build’s SSR + index.html emit zero inline style= / <style>, and the CspNonceLayer nonces every <script> — so strict style-src 'self' + script-src 'self' 'nonce-…' 'wasm-unsafe-eval' holds with NO 'unsafe-inline' and no hash-based fallback. The only framework style emission is dx’s debug hot-reload toast (an inline <style> + a Google-Fonts @import), which is stripped in release — a dev-only concern, handled by a dev-only CSP relaxation or by disabling the dx toast when the local-dev dx serve workflow is set up. (#630).

Architecture

┌──────────────── canopy-portal (Dioxus 0.7+ fullstack) ────┐
│                                                           │
│  Routes (canopy-portal's OWN router, OUTSIDE canopy-api): │
│  /          → Welcome                                     │
│  /apply     → 4-step Apply form                           │
│  /submitted → Credential reveal (ONE-TIME)                │
│  /lookup    → Application ID + Passcode login             │
│             (rate-limited cascade; calls canopy-applic.   │
│              POST /v1/applicants/verify-credential        │
│              internally via service-token)                 │
│  /recover   → 4-step recovery flow                        │
│  /home      → 6 case-state heroes (auth-required)         │
│  /documents → upload + list (auth-required)               │
│  /verifications → inbox (auth-required)                   │
│                                                           │
│  Sessions: Redis-primary (ADR-026). Portal is             │
│  Postgres-free; no anonymous PostgreSQL session.          │
│  Opaque 256-bit token: session:{token_hash} ->            │
│  {application_id, code, device_id, flow_kind,             │
│  expires_at}. DEL=kill-switch; minted draft-start.        │
│                                                           │
│  Applicant-ownership middleware: every auth route asserts │
│  session.application_id == path.application_id.           │
│                                                           │
│  Server functions (route attrs #[get]/#[post]):           │
│  - submit_application() → POST canopy-applications        │
│  - upload_document() → POST canopy-applications           │
│  - record_ele_consent() → POST Plan-2 endpoint            │
│  - All cross-service calls: service-token +               │
│    X-Canopy-Actor: applicant:<application_id_uuid>        │
│                                                           │
│  Rate-limiting cascade (Redis namespace                   │
│  canopy-portal-ratelimit):                                │
│  - per-device cookie (primary; 5/hr, 10/day)              │
│  - per-CaseID (always; 8 wrong/day)                       │
│  - per-IP fallback (300/hr default; 3000/hr CGNAT)        │
│  - CAPTCHA verifier (noop; provider deferred #663)        │
└───────────────────────────────────────────────────────────┘
                         │  (service-token + X-Canopy-Actor)
                         ▼
┌──────────────── canopy-applications ─────────────────────┐
│  POST /v1/applicants/verify-credential (NEW; internal)    │
│       called by canopy-portal lookup handler              │
│                                                           │
│  POST /v1/applications/{create-draft,finalize} (NEW)      │
│       create-draft mints reserved id + creds; finalize    │
│       creates persons+household+income+application with   │
│       the reserved id + DELETEs draft (one tx; ADR-026)   │
│                                                           │
│  POST /v1/applications/{id}/documents (NEW, multipart)    │
│       AppState gains scanner + object_store              │
│       Body limit override: 10 MiB                         │
│       Per-program S3 prefix: {program}/{app_id}/{sha256}  │
│       IDOR check via assert_owns_application              │
│                                                           │
│  POST /v1/applicants/recover/initiate (NEW; 4-step)       │
│       Mints 24h pending recovery; emits outbox            │
│  POST /v1/applicants/recover/kill/{token} (NEW)           │
│       Kill-switch endpoint                                │
│                                                           │
│  POST /v1/applications/{id}/ele-consent (PLAN 2 MR3)      │
│       Plan 3 MR6 Apply form calls this                    │
│                                                           │
│  Scheduler: services/canopy-applications/src/scheduler.rs │
│  (NEW; mirrors canopy-renewals pattern)                   │
│  Daily tick prunes expired/killed recovery_pending rows   │
└───────────────────────────────────────────────────────────┘
                         │
                         ▼
┌──────────────── canopy-notices ──────────────────────────┐
│  Subscriber: application.applicant.recovery_initiated     │
│  Sends email + SMS to application-time contact            │
│  (from applications.notify_email + notify_phone_e164)     │
│  One-tap kill-switch URL in notification                  │
└───────────────────────────────────────────────────────────┘
                         │
                         ▼
┌──────────────── canopy-verification ─────────────────────┐
│  GET /v1/verifications?application_id={id}&status=pending │
│       (NEW filter; de-conflict with Plan 2 MR2)           │
│  POST /v1/verifications/{id}/respond (NEW)                │
│       Accepts Vec<DocumentId>; inserts                    │
│       verification_responses rows                         │
│                                                           │
│  Scripted IEVS + SAVE adapters (TOML config;              │
│  household_id-keyed; in services/canopy-verification/     │
│  src/scripted.rs alongside noop.rs)                       │
└───────────────────────────────────────────────────────────┘
                         │
                         ▼
┌──────────────── canopy-web ──────────────────────────────┐
│  case-detail Verifications section: STUB REPLACED         │
│  (services/canopy-web/src/case_detail/sections/           │
│   verifications.rs, was #562 stub)                        │
│  Lists open + closed verifications per household          │
│                                                           │
│  case-detail Documents section: STUB REPLACED             │
│  (services/canopy-web/src/case_detail/sections/           │
│   documents.rs, was #562 stub)                            │
│  Lists uploaded documents + preview + accept/reject       │
└───────────────────────────────────────────────────────────┘

Schema additions

Forward-only per ADR-016. Migration timestamps are PLACEHOLDERS — every MR’s actual migration filename uses <MR_MERGE_DATE> (UTC YYYYMMDDHHMMSS) at write-time. Ordering between Plan 3 migrations is preserved by suffix bytes.

<MR_MERGE_DATE>_create_application_id_codes.sql (MR4)

-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Plan 3 MR4 — applicant-facing Application ID (HH-[a-f0-9]{8}).

CREATE TABLE application_id_codes (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    -- Reserved id: NO FK to applications. The credential is minted at
    -- draft-start (ADR-026 reserved-id lifecycle) before any applications
    -- row exists; integrity is enforced by finalize + the reaper.
    application_id UUID NOT NULL UNIQUE,
    code TEXT NOT NULL UNIQUE CHECK (code ~ '^HH-[a-f0-9]{8}$'),
    generated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX application_id_codes_code_idx ON application_id_codes (code);

<MR_MERGE_DATE>_create_passcode_hashes.sql (MR4)

-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Plan 3 MR4 — applicant passcode (argon2id-hashed).
-- NOTE: NO table-level UNIQUE on application_id (would block rotation).
-- Only the partial unique index below enforces active-row uniqueness;
-- revoked rows persist for audit.

CREATE TABLE passcode_hashes (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    -- Reserved id: NO FK to applications (ADR-026 reserved-id lifecycle).
    application_id UUID NOT NULL,
    passcode_hash TEXT NOT NULL,
    -- Passcode is NNNN-NNNN-NNNN (twelve digits, ADR-008 Amendment 3) —
    -- language-independent, so no wordlist word_count / language columns.
    digit_count INT NOT NULL DEFAULT 12 CHECK (digit_count = 12),
    generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    revoked_at TIMESTAMPTZ,
    revoke_reason TEXT
);

CREATE UNIQUE INDEX passcode_hashes_active_per_app
    ON passcode_hashes (application_id) WHERE revoked_at IS NULL;

<MR_MERGE_DATE>_extend_applications_notify_columns.sql (MR4)

-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Plan 3 MR4 — extend applications for applicant-portal design ref §3.7 application-time contact.

ALTER TABLE applications ADD COLUMN notify_email TEXT;
ALTER TABLE applications ADD COLUMN notify_phone_e164 TEXT;

20260603000000_create_application_drafts.sql (MR6a)

-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Plan 3 MR6 — client-side-encrypted Apply-form draft (ADR-026).
-- Keyed on the RESERVED application_id (NO FK to applications; the row is
-- created only at finalize). The server stores ciphertext it cannot bulk-read.

CREATE TABLE application_drafts (
    application_id UUID PRIMARY KEY,       -- reserved id; NO FK to applications
    kdf_salt       BYTEA NOT NULL,         -- per-draft Argon2id salt (non-secret)
    ciphertext     BYTEA NOT NULL,         -- XChaCha20-Poly1305, client-encrypted
    nonce          BYTEA NOT NULL,
    enc_version    SMALLINT NOT NULL,
    current_step   INT NOT NULL CHECK (current_step BETWEEN 1 AND 4),
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_saved_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at     TIMESTAMPTZ NOT NULL    -- sliding: last_saved_at + 30 days
);

CREATE INDEX application_drafts_expires_idx
    ON application_drafts (expires_at);

<MR_MERGE_DATE>_extend_applications_recovery_gate.sql (MR8a, #634)

The recovery gate columns on applications (as built — 20260604000000). confidentiality drives the confidential-case block (applicant-portal design ref §3.8 — confidential/both route to phone); recovery_locked is the durable case-lock the kill-switch installs (a boolean, NOT a status value — a recovery_locked status would pollute every status consumer).

ALTER TABLE applications
    ADD COLUMN confidentiality TEXT NOT NULL DEFAULT 'standard'
        CHECK (confidentiality IN ('standard', 'confidential', 'address_confidential', 'both'));

ALTER TABLE applications
    ADD COLUMN recovery_locked BOOLEAN NOT NULL DEFAULT false;

<MR_MERGE_DATE>_create_recovery_pending.sql (MR8)

-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Plan 3 MR8 — 24h pending-recovery state with kill-switch.

CREATE TABLE recovery_pending (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    application_id UUID NOT NULL REFERENCES applications(id),
    initiated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    reveal_at TIMESTAMPTZ NOT NULL,
    kill_switch_token TEXT NOT NULL UNIQUE,
    killed_at TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    initiator_ip INET,
    initiator_device_cookie TEXT,
    notify_email TEXT,
    notify_phone_e164 TEXT,
    notification_sent_at TIMESTAMPTZ
);

CREATE UNIQUE INDEX recovery_pending_active_per_app
    ON recovery_pending (application_id)
    WHERE killed_at IS NULL AND completed_at IS NULL;

CREATE INDEX recovery_pending_reveal_due_idx
    ON recovery_pending (reveal_at)
    WHERE completed_at IS NULL AND killed_at IS NULL;

<MR_MERGE_DATE>_create_application_documents.sql (MR9)

-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Plan 3 MR9 — applicant document uploads.

CREATE TABLE application_documents (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    application_id UUID NOT NULL REFERENCES applications(id),
    person_id UUID NOT NULL,
    -- PURPOSE axis (what the doc proves): SAME closed set as canopy-verification's
    -- verifications.verification_type — per ADR-001 verification owns this vocabulary;
    -- do NOT fork a parallel enum (ideally the row also references the verification item it satisfies).
    document_type TEXT NOT NULL
        CHECK (document_type IN ('identity','income','residency','citizenship','other')),
    -- FORM axis (what the doc IS): optional worker-facing metadata = HANDOFF DocumentKind.
    document_kind TEXT
        CHECK (document_kind IS NULL OR document_kind IN ('photo_id','ssn_card','pay_stub','lease','other')),
    original_filename TEXT NOT NULL,
    sanitized_filename TEXT NOT NULL,
    content_type TEXT NOT NULL,
    size_bytes BIGINT NOT NULL,
    sha256 BYTEA NOT NULL,
    s3_bucket TEXT NOT NULL,
    s3_key TEXT NOT NULL,
    scan_status TEXT NOT NULL DEFAULT 'noop'
        CHECK (scan_status IN ('noop','clean','infected','skipped','error')),
    uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    uploaded_by_source TEXT NOT NULL
        CHECK (uploaded_by_source IN ('applicant_portal','worker_intake')),
    accepted_at TIMESTAMPTZ,
    accepted_by UUID,
    rejection_reason TEXT
);

CREATE INDEX application_documents_app_idx
    ON application_documents (application_id, uploaded_at DESC);
CREATE INDEX application_documents_person_idx
    ON application_documents (person_id);
CREATE INDEX application_documents_pending_idx
    ON application_documents (application_id, document_type)
    WHERE accepted_at IS NULL AND rejection_reason IS NULL;

<MR_MERGE_DATE>_create_verification_responses.sql (MR10, canopy-verification)

-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Plan 3 MR10 — applicant respond surface for verifications.

CREATE TABLE verification_responses (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    verification_id UUID NOT NULL REFERENCES verifications(id),
    document_id UUID,                     -- references application_documents.id (cross-service)
    application_id UUID NOT NULL,
    person_id UUID NOT NULL,
    response_text TEXT,
    responded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    responded_by_source TEXT NOT NULL
        CHECK (responded_by_source IN ('applicant_portal','worker_intake'))
);

CREATE INDEX verification_responses_verification_idx
    ON verification_responses (verification_id, responded_at DESC);
CREATE INDEX verification_responses_application_idx
    ON verification_responses (application_id);

Rate-limiting cascade (canopy-portal/src/ratelimit.rs)

Per applicant-portal design ref §3.6, the cascade primary-keys on device cookie, NOT IP. Tower middleware applied to lookup + apply endpoints (and recovery, when it lands in MR8). As built (MR7): a RateLimiter enum (Redis \| Disabled, fail-open) over the noeviction redis-sessions keyspace; the device/IP tiers run in a from_fn middleware (uniform 429 + Retry-After), the per-CaseID "8 wrong/day" cap runs in the /lookup handler (a pre-request middleware can’t know whether a credential verifies). The CAPTCHA gate is MR8b (it protects /recover initiate completion), and as re-decided 2026-05-31 MR8b ships only the verifier abstraction with noop default — the real provider (preferred org mCaptcha PoW, fallback hCaptcha/Turnstile) is deferred to #663, so the cascade is the live recovery-abuse control until then. The trait sketch below was realised as the enum; the limits match. The recovery endpoints (POST /v1/applicants/recover/*) landed service-caller-only in MR8a; the portal /recover proxy that the existing route_layer cascade covers is MR8b.

pub enum RateLimitDecision {
    Allow,
    Reject { retry_after_seconds: u32 },
}

#[async_trait]
pub trait RateLimitBackend: Send + Sync {
    async fn check_and_increment(
        &self,
        device_cookie: Option<&str>,
        case_id: Option<&str>,
        client_ip: &IpAddr,
    ) -> anyhow::Result<RateLimitDecision>;
}

pub struct RedisRateLimitBackend {
    redis: redis::aio::ConnectionManager,
    per_device_hourly: u32,        // 5
    per_device_daily: u32,         // 10
    per_case_id_daily: u32,        // 8
    per_ip_hourly_default: u32,    // 300
    per_ip_hourly_cgnat: u32,      // 3000
    cgnat_subnets: Vec<IpNetwork>, // AT&T / T-Mobile / Verizon ranges
}

Same generic 401 error for all rejections (applicant-portal design ref §3.6: never reveal which limit was hit). CAPTCHA gate on /recover initiate completion via the config-driven verifier abstraction (CANOPY_PORTAL__CAPTCHA_PROVIDER=…|noop); MR8b ships noop, real provider + any SOPS secret deferred to #663 (preferred org mCaptcha PoW, fallback hCaptcha/Turnstile, never reCAPTCHA).

Credential generation (canopy-applications/src/credentials/)

pub fn generate_application_id() -> String {
    format!("HH-{:08x}", rand::random::<u32>())
}

pub fn generate_passcode() -> String {
    let mut rng = thread_rng();
    // 12-digit passcode, three dash-separated groups of four. ONE uniform
    // CSPRNG draw over the full 0..10^12 space (leading zeros valid; dashes
    // cosmetic) — never per-digit loops that could bias, never leading-zero
    // avoidance, never a non-CryptoRng source.
    let n: u64 = rng.gen_range(0..1_000_000_000_000); // rng: CryptoRng + RngCore
    let digits = format!("{n:012}");
    let passcode = format!("{}-{}-{}", &digits[0..4], &digits[4..8], &digits[8..12]);
    passcode
}

pub fn hash_passcode(passcode: &str) -> anyhow::Result<String> {
    use argon2::{Argon2, PasswordHasher, password_hash::{SaltString, rand_core::OsRng}};
    let salt = SaltString::generate(&mut OsRng);
    Ok(Argon2::default().hash_password(passcode.as_bytes(), &salt)?.to_string())
}

pub fn verify_passcode(passcode: &str, hash: &str) -> anyhow::Result<bool> {
    use argon2::{Argon2, PasswordHash, PasswordVerifier};
    let parsed_hash = PasswordHash::new(hash)?;
    Ok(Argon2::default().verify_password(passcode.as_bytes(), &parsed_hash).is_ok())
}

// ID-collision retry (v6 reviewer P1 #11): birthday collisions become
// likely around 65K applications. Wrap INSERT in bounded retry loop
// on UniqueViolation.
pub async fn insert_application_id_with_retry(
    tx: &mut Transaction<'_, Postgres>,
    application_id: ApplicationId,
) -> Result<String, ApiError> {
    for _attempt in 0..10 {
        let code = generate_application_id();
        match sqlx::query!(
            "INSERT INTO application_id_codes (application_id, code) VALUES ($1, $2)",
            application_id as _, code
        ).execute(&mut **tx).await {
            Ok(_) => return Ok(code),
            Err(sqlx::Error::Database(e)) if e.is_unique_violation() => continue,
            Err(e) => return Err(e.into()),
        }
    }
    Err(ApiError::internal("could not generate unique application code after 10 attempts"))
}

Outbox events (PII-allowlist per ADR-014)

Five new events emitted by canopy-applications, all prefixed application.applicant.* matching existing application.submitted convention at services/canopy-applications/src/events.rs:15:

ApplicantCredentialsGeneratedEvent {
    application_id: ApplicationId,
    code: String,                       // HH-[a-f0-9]{8} — non-secret
    generated_at: DateTime<Utc>,
    // NO passcode (or hash) in event payload.
}

DocumentUploadedEvent {
    application_id: ApplicationId,
    document_id: DocumentId,
    person_id: PersonId,
    document_type: String,
    size_bytes: u64,
    sha256_hex: String,
    uploaded_at: DateTime<Utc>,
}

// As built (MR8a): the kill-switch token AND the initiator IP/subnet are
// deliberately NOT in the payload — the token is a CAPABILITY secret (keeping it
// off the broadcast bus + the security audit log) and the IP is needless PII.
// canopy-notices (MR8c) reads the token + the notify contact from the
// recovery_pending row by recovery_id via a service-token call.
"application.applicant.recovery_initiated" {
    application_id: ApplicationId,
    recovery_id: Uuid,
    reveal_at: DateTime<Utc>,
}

"application.applicant.recovery_killed" {
    application_id: ApplicationId,
    recovery_id: Uuid,
}

// Emitted when self-serve recovery is attempted on a confidential / recovery-locked
// case (applicant-portal design ref §3.8) — worker-visible flag.
"application.applicant.recovery_confidential_blocked" {
    application_id: ApplicationId,
}

NO SSN, NO DOB, NO income, NO street address. NO passcode (or hash), NO kill-switch token, NO IP in any event.

canopy-portal Dioxus 0.7+ rewrite (MR1b + MR2 + MR3)

services/canopy-portal/Cargo.toml adds (verified against IMTN Cargo.toml:18,65-68):

[dependencies]
dioxus = { version = "0.7.9", features = ["fullstack", "router"] }   # latest stable; 0.8 is alpha
# The portal is Postgres-free (ADR-026): no sqlx / canopy-db / store. The
# client-side draft-encryption crates (ADR-026) run in the WASM build, so they
# are CORE (both targets), not server-gated. Remaining server-only crates (the
# CSP-layer Tower stack, tokio, the Fluent i18n stack, tracing) are
# `optional = true` and turned on by the `server` feature.
argon2           = "0.5"     # client-side draft-key KDF (WASM, ADR-026)
chacha20poly1305 = "0.10"    # client-side draft AEAD (WASM, ADR-026)
# NOTE: MR5 adds `redis` to the root `[workspace.dependencies]` and declares it
# server-gated here for the Redis-primary session store + rate-limit cascade.

[features]
server = ["dioxus/server"]   # + server-only optional deps as MR4/MR5 add them

Toolchain (verified against IMTN Dockerfile:18-35 + rust-toolchain.toml): add wasm32-unknown-unknown (pin it in rust-toolchain.toml so all devs/CI get it — preferred over IMTN’s build-time-only rustup target add); cargo install dioxus-cli@0.7.9 --locked; build with dx build --release --platform web --fullstack --package canopy-portal; ship a minimal Dioxus.toml. (IMTN’s [profile.release] strip = "symbols" — a wasm-opt DWARF-crash workaround — was NOT needed on 0.7.9: the release build is clean without it, and strip is a workspace-wide [profile.release] setting, so the WASM-size optimization is deferred.) Drop IMTN’s TLS deps (rcgen/rustls/tokio-rustls — canopy terminates TLS at the proxy).

// services/canopy-portal/src/main.rs (rewritten in MR1b)
//
// NO LaunchBuilder. The manual Axum router is REQUIRED for strict CSP:
// Dioxus fullstack SSR emits an inline <script> carrying hydration state;
// under script-src without 'unsafe-inline' the browser blocks it and the
// app never hydrates. The fix is a response-body-rewriting Tower layer
// (CspNonceLayer) that injects a per-response nonce into every <script> —
// only possible if WE own the Axum router. dioxus::launch/LaunchBuilder
// expose no body-rewrite hook, so they would force script-src 'unsafe-inline'.
#[cfg(feature = "server")]
fn main() {
    tokio::runtime::Runtime::new().unwrap().block_on(async {
        let router = dioxus::server::router(App)   // Dioxus base (greedy: unmatched → 404)
            .route("/healthz", /* handler */)
            .route("/metrics", /* handler */)
            .layer(csp::CspNonceLayer);            // body-rewrite nonce + strict CSP header
        // No sessions in MR1b — Redis-primary sessions (ADR-026) land in MR5;
        // the portal is Postgres-free, so there is no tower-sessions layer.
        // bind settings.port; axum::serve(listener, router.into_make_service())
    });
}
#[cfg(not(feature = "server"))]
fn main() { dioxus::launch(App); }                 // client/WASM entry

// Dioxus 0.7.9 Routable — #[layout(...)]/#[end_layout] groups routes under a
// layout component that renders Outlet (NOT #[nest], which only adds a URL
// prefix). Layouts wrap the Outlet in an ErrorBoundary. Router mounts at the
// app root as Router::<Route> {} after the document::Stylesheet { .. }.
#[derive(Routable, Clone, PartialEq)]
enum Route {
    #[layout(PublicLayout)]
      #[route("/")]            Welcome {},
      #[route("/apply")]       Apply {},
      #[route("/submitted")]   Submitted {},
      #[route("/lookup")]      Lookup {},
      #[route("/recover")]     Recover {},
    #[end_layout]
    #[layout(AuthedLayout)]
      #[route("/home")]          Home {},
      #[route("/documents")]     Documents {},
      #[route("/verifications")] Verifications {},
    #[end_layout]
    #[route("/:..segments")]   NotFound { segments: Vec<String> },
}

Strict CSP regime (user-locked 2026-05-27): per-response nonce on <script>; wasm-unsafe-eval permitted (Dioxus WASM instantiation requires it; the only -unsafe- directive in the policy). NO 'unsafe-inline' for styles, NO 'unsafe-eval', NO 'unsafe-hashes'. ADR-008 §214’s current permission for inline styles is superseded by MR1a’s amendment (#630). Every Dioxus component ships class-only styling against the static stylesheet at services/canopy-portal/assets/canopy-portal.css.

CSP mechanism (resolved by the IMTN study, 2026-05-29). Adopt IMTN’s src/csp.rs CspNonceLayer Tower middleware wholesale: it buffers each HTML response, injects a per-response 16-byte base64 nonce into every <script> (csp.rs:43-76), and sets the CSP header (csp.rs:110-156). This is what makes script-src 'self' 'nonce-{n}' 'wasm-unsafe-eval' hold with NO 'unsafe-inline' — Dioxus SSR emits an inline hydration <script> that the layer nonces. The script half of the gate is therefore proven by IMTN. The middleware is also why the manual dioxus::server::router is mandatory (it must own the response to rewrite the body — §9 sample). Canopy’s policy differs from IMTN in exactly one directive: style-src 'self' (IMTN keeps 'unsafe-inline' for styles). The style half is the only open spike. IMTN’s style-src 'unsafe-inline' is heavily load-bearing for IMTN — its authors wrote ≈328 style: props across src/ (e.g. imtn/src/components/child_card.rs:40). That count is the point: IMTN’s evidence is "the framework didn’t force inline styles on us," NOT "the framework emits none." So canopy’s discipline of writing zero style: props is real, non-trivial work. RESOLVED (MR1b, 2026-05-29): the spike PASSED on Dioxus 0.7.9. With zero author style: props, the release build’s SSR + index.html emit zero inline style= / <style>, and the CspNonceLayer nonces every <script> — so strict style-src 'self' holds with no 'unsafe-inline' and no hash-based fallback. The only framework style emission is dx’s debug hot-reload toast (an inline <style> + a Google-Fonts @import), stripped in release (dev-only). Hard rule for MR1c + every component MR: never write style: props; dark mode is body[data-theme="dark"] CSS-var overrides only (IMTN src/state/theme.rs:20-41).

RBAC (extending Plan 2’s new Claims guard)

Plan 2 MR3 declares Claims::require_service_or_applicant_or_caseworker_or_above() in crates/canopy-auth/src/claims.rs. Plan 3 reuses extensively + adds new assert_owns_application(claims, path_application_id) → Result<(), ApiError> helper for IDOR protection on canopy-applications endpoints called via X-Canopy-Actor: applicant:<application_id> header.

Worker-only endpoints (case-detail-documents accept/reject) require Claims::require_caseworker_or_above().

Demo script (Plan 3 contribution to the 10-min combined video)

Time budget: Plan 1 ~3.5 min + Plan 2 ~2.5 min + Plan 3 ~4 min = 10 min total. Recovery flow is BUILT in Plan 3 but NOT demoed (would add 2+ min).

0:00  Open canopy-portal in browser. Welcome screen.
0:30  Step 1-4: Apply (programs → household → contact → income → submit).
       ELE consent prompt (Plan 2 MR3 endpoint). Applicant clicks YES.
2:00  Submitted screen. Big credential card: "HH-c8841a23 / 4821-0073-
       9156". "Save this — we will never show your passcode again."
       Copy / download / screenshot prompts. Applicant takes screenshot.
2:30  Worker (Plan 1) sees Maria in MyQueue. Sends Verification Checklist.
2:45  Cut back to applicant portal. Logout. Lookup with HH-c8841a23 +
       passcode → mints opaque session → /home → "Action needed: 4 verifications".
3:15  Applicant clicks /documents → uploads ID + utility bill (PDF, JPEG).
       canopy-applications validates + stores in Garage S3 with per-program prefix.
3:45  Applicant clicks /verifications → responds to each pending verification
       attaching the uploaded documents.
4:00  Cut back to worker portal. Worker sees uploaded documents in the
       case-detail Documents section. Accepts both.
4:30  Cut to Plan 2 ELE grant + extend recap.

Verification

cargo xtask dev refresh
cargo xtask seed --profile demo --reset

# DB sanity
psql -c "SELECT COUNT(*) FROM application_id_codes;"
# Expect: ≥ 3 (Maria, Carlos, Tanya seeded by MR11)
psql -c "SELECT COUNT(*) FROM passcode_hashes WHERE revoked_at IS NULL;"
# Expect: ≥ 3

# Format adherence
psql -c "SELECT code FROM application_id_codes;" \
  | grep -E '^HH-[a-f0-9]{8}$' | wc -l
# Expect: ≥ 3

# API surface (applicant-side via lookup; worker-side via worker-token)
PORTAL_PORT=$(grep '^CANOPY_PORT_CANOPY_PORTAL_8090=' .ports.env | cut -d= -f2)
TOKEN=$(curl -X POST "http://localhost:${PORTAL_PORT}/lookup" \
  -H "Content-Type: application/json" \
  -d '{"code":"HH-c8841a23","passcode":"4821-0073-9156"}' \
  | jq -r .session_token)

curl -H "Authorization: Bearer $TOKEN" \
  "http://localhost:${PORTAL_PORT}/v1/applications/{id}/documents"

# Rate-limiting cascade
for i in {1..6}; do
  curl -X POST "http://localhost:${PORTAL_PORT}/lookup" \
    -H "Content-Type: application/json" \
    -d '{"code":"HH-00000000","passcode":"0000-0000-0001"}' \
    -i | head -1
done
# Expect: 5x 401, then 1x 429 (per-device cookie limit hit)

# Test gates
cargo nextest run -p canopy-portal -p canopy-applications -p canopy-verification \
                  -p canopy-notices -p canopy-web -p canopy-common -p canopy-store \
                  -p canopy-auth
cargo xtask e2e -- applicant-portal.spec.ts

# Project gates
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; applicant-portal.spec.ts walks Welcome → Apply → Submitted (credential reveal asserted) → Logout → Lookup → /home → /documents upload → /verifications respond → worker-side case-detail-documents + case-detail-verifications sections show the uploads + responses. ≥60 new test cases total: ≥20 on canopy-portal (route handlers + component snapshots + rate-limit cascade + recovery flow), ≥15 on canopy-applications, ≥6 on canopy-notices, ≥5 on canopy-web, ≥4 on canopy-common, ≥5 on canopy-verification, ≥5 on canopy-store.

Risks & mitigations

  • First Dioxus introduction: no in-repo precedent. Mitigation: MR1b ships an Antora integration doc; the patterns are resolved + verified against the Georgia DHS IMTN Dioxus 0.7.3 portal (ADR-008) — concretely imtn/src/csp.rs (nonce layer), src/routes.rs ([layout]/Outlet), src/main.rs (dioxus::server::router bootstrap), src/server/*.rs ([get]/#[post] server fns), src/state/theme.rs ([data-theme] dark mode). IMTN’s auth/session/i18n are NOT donors (see below).

  • WASM bundle size: target ≤2 MB initial, ≤5 MB per-route (gzipped). MR11 Playwright measures + fails CI if exceeded. IMTN has no bundle-size gate (only strip = "symbols"), so this is net-new for canopy — implement as a post-dx build gzip-size check.

  • Asset serving + compression: the static stylesheet is served via Dioxus’s compile-time asset!() macro (IMTN-proven — imtn/src/app.rs:9-13 declares the assets, :58-66 renders them via document::Stylesheet; no brotli needed for CSS). Garage does NOT do content compression, so for the large WASM bundle ONLY, MR1b ships a brotli-pre-compressed .wasm.br and canopy-portal’s Axum serves it via an Accept-Encoding: br middleware. (Net-new vs IMTN, which does no brotli at all — flag as an unproven-against-IMTN bandwidth optimization; drop it if it complicates the dx/asset pipeline.)

  • CAPTCHA provider fit: a third-party provider (Cloudflare account, deanonymization, accessibility of a widget/image CAPTCHA) is a poor fit for an accessibility-first, intimate-threat recovery flow. Mitigation (re-decided 2026-05-31, #663): MR8b ships a config-driven verifier abstraction with noop default and the rate-limit cascade as the interim control; the preferred eventual provider is the org-hosted mCaptcha (PoW) service (accessible-by-construction, self-hosted), fallback hCaptcha/Turnstile, never reCAPTCHA. CSP impact is provider-derived (none for noop).

  • Rate-limiting bypass: cookie-deleting attacker loops to per-IP fallback. applicant-portal design ref §3.6 acknowledges; per-CaseID cap (8 wrong/day) caps total brute-force progress. MR7 boundary (as built): the per-CaseID cap only protects /lookup; /apply/start (which mints a fresh draft each call) has no per-target equivalent, so a cookie-stripping client’s draft creation is bounded only by the per-IP tier (300/hr; 3000/hr CGNAT). This is acceptable — abandoned reserved-id drafts are swept by the MR6d sliding reaper and create no operator-readable PII — but if draft-spam becomes a problem, a per-IP /apply/start sub-cap or the MR8 CAPTCHA on draft-start is the lever.

  • CGNAT false positives: heavily-used carrier IP could trip 300/hr. Mitigation: 3000/hr on known CGNAT subnets per applicant-portal design ref §3.6.

  • Recovery flow notification to compromised contact: applicant-portal design ref §3.7 — notification goes to application-time contact, not recently-changed. Schema captures notify_email + notify_phone_e164 snapshot at recovery_pending insertion time.

  • Argon2id parameter drift: project-standard params not yet uniform. Mitigation: MR4 imports canopy_common::password::argon2_default_params (existing per ADR-019); applicant-side uses the same.

  • canopy-seed bypass for credentials: MR11 seeds passcode_hashes via raw INSERT. Mitigation: payloads validated via the same generate_passcode + hash_passcode flow the API uses. Demo runbook page lists seeded passcodes.

  • ADR-008 amendment scope: declaring §82 + §214 superseded touches an existing-and-acknowledged ADR. Mitigation: MR1a amendments are additive supersession sections, not deletions.

  • Plan 2 MR3 dependency: Plan 3 MR6 cannot ship until Plan 2 MR3 has merged. Mitigation: ordering documented in §2 + §11; MR3 of Plan 2 is small (~700 LOC) and is the first heavy MR of Plan 2.

References

ADRs to honor + amend

Existing code to extend

  • services/canopy-portal/Cargo.toml — Dioxus + argon2 + redis deps added in MR1b

  • services/canopy-portal/src/main.rs:21-95 — rewritten from the Axum stub to a Dioxus fullstack server via dioxus::server::router(App) in MR1b (NOT LaunchBuilder — required for strict CSP; see §9)

  • services/canopy-portal/src/i18n.rs — Fluent loader (kept; Dioxus consumes)

  • (No wordlist module — the passcode is twelve uniform-random digits per ADR-008 Amendment 3; the CSPRNG generator lives in MR4’s canopy-applications/src/credentials/, not a shared crate.)

  • crates/canopy-auth/src/claims.rs:198,240require_applicant, require_service_or_caseworker_or_above (existing); Plan 3 adds assert_owns_application helper

  • crates/canopy-auth/src/client_ext.rs:33ACTOR_HEADER constant for X-Canopy-Actor (existing)

  • services/canopy-applications/src/api/mod.rs — append new routes for credentials + orchestration + documents + recovery + verify-credential + ele-consent

  • crates/canopy-store/src/validation.rsvalidate_upload reused by MR9 document upload (existing per the canopy-store upload validation plan)

  • services/canopy-web/src/case_detail/sections/documents.rs:21 + verifications.rs:21 — Plan 1 #562 stubs replaced in MR9 + MR10

  • services/canopy-web/src/api/case_detail.rs:2644-2781 — existing worker request_verification handler (NOT touched)

  • tools/canopy-seed/src/demo/personas.rs:380 — extend Maria archetype with application_id_codes + passcode_hashes rows in MR11

  • devstack/fixtures/ — NEW directory; MR10 creates with {ievs,save}-scripts.toml

  • design/canopy-portal/ — design package; line-for-line port targets

Conventions

  • .claude/docs/delivery-protocol.md — Q1-Q8 per MR

  • .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 (deferred to post-Plan-3 follow-ups)

  • Phases 5-8 from HANDOFF.md §11 (Messages + SSE, Renewal + change-of-info, Discover, Recap, Spanish-beyond-applicant-surfaces, EBT card, prefers-* hooks).

  • canopy-portal Spanish (es) BEYOND the applicant-portal surfaces.

  • ClamAV virus-scanner integration (NoopScanner in devstack; real ClamAV configurable post-Plan-3).

  • Kiosk-mode launch flag (15-min session) — HANDOFF flag for post-Plan-3.

  • Push notification opt-in (HANDOFF Phase 5).

  • Recovery flow live demonstration in the 10-min video (built but not demoed).

Revision history

  • v1: initial draft after 4-question architectural lock-in + 6-gap evidence subagent + user strict-CSP clarification.

  • v2: contextless reviewer found 6 P0 + 6 P1 + 6 P2:

    • P0 #1+#2 (DocumentId + Claims guard prereqs): Plan 1 + Plan 2 plans are in Antora but their IMPLEMENTATION MRs have not landed. v2 introduces a "shared-deliverable de-conflict" rule in §2 — Plan 3 MR1 checks git log origin/main at branch-off time + declares OR skips each artifact as appropriate; no double-declarations.

    • P0 #3 (devstack path): corrected devstack/docker-compose.yml → repo-root docker-compose.yml:78-85.

    • P0 #4 (canopy-applications scheduler doesn’t exist): MR8 now explicitly introduces services/canopy-applications/src/scheduler.rs mirroring canopy-renewals + Plan 2’s canopy-medicaid pattern.

    • P0 #5 (CSP claim unverified): MR1 now includes a load-bearing CSP spike to verify Dioxus 0.7 can be configured to emit zero style="" attributes; if not, fall back to hash-based style-src CSP directive (still no 'unsafe-inline').

    • P0 6 (Dioxus 0.7 Routable syntax): [layout(…​)] is 0.6 syntax; 0.7 uses [nest] + [end_nest] with Outlet rendering. §9 sample updated.

    • P0 #9 (lookup endpoint ownership): POST /v1/applicants/lookup moves to canopy-portal (rate-limit cascade lives there); canopy-applications gets internal POST /v1/applicants/verify-credential instead. MR5 row + §3 ASCII updated.

    • P1 #10 (migration timestamp collision): re-stamped by MR ordering. MR4 = 20260615000000+20260615000001; MR8 = 20260615001000; MR9 = 20260615002000. ADR-016 monotonicity preserved.

    • P1 #11 (routing-key convention): switched applications.applicant.application.applicant. matching existing application.submitted at events.rs:15.

    • P1 #12 (port number uncited): §13 verification block now derives PORTAL_PORT from .ports.env instead of literal :8086.

  • v3: v2 reviewer found 5 issues v2 introduced + 8 v1 P1/P2 findings unaddressed in §17. v3 fixes:

    • Self-contradicting Dioxus syntax: §9 sample stripped of misleading 0.6/0.7 hybrid; replaced with placeholder enum Route { /* MR1 spike fills this in */ } + the route list as plain English. Spike outcome documents the actual syntax.

    • Port grep mismatch: CANOPY_PORT_PORTALCANOPY_PORT_CANOPY_PORTAL_8090 (actual env-var name in .ports.env).

    • Migration-timestamp prose contradiction: §4 prose now explicitly marks 20260615* as PLACEHOLDER (not literal); ordering relationship between MR4/MR8/MR9 is canonical via the suffix bytes; actual timestamps <MR_MERGE_DATE> at write time.

    • Citation drift on docker-compose line range: 78-8577-90 (covers the full Redis block).

    • MR1 split: MR1 → MR1a (ADR amendments) + MR1b (Dioxus scaffold + spike + de-conflict declarations) + MR1c (stylesheet). LOC budgets per split sub-MR.

    • §17 accountability for v1 P1 #7/#8 + 6 P2s (per feedback_no_deferral_accountability):

      • v1 P1 #7 (MR1 LOC undersized for WASM toolchain spike): addressed by the MR1 split — 1b explicitly scopes the spike.

      • v1 P1 #8 (ADR-025 validator entry shape): MR9 row spells out application_documents.application_id → applications.id + application_documents.person_id → persons.persons.id.

      • v1 P2 #13 (:08x lowercase vs "uppercase" prose): §6 generator uses format!("HH-\{:08x\}", …​) which produces lowercase hex; CHECK constraint at §4 matches (^HH-[a-f0-9]{8}$); the §11 MR4 prose was already lowercase. No "uppercase" claim remains; reviewer was citing the wrong sentence.

      • v1 P2 #14 (jurisdictions language constraint): MR4 description notes language IN ('en','es') matches applicant-portal design ref §3.2 + ADR-008 §140-141 (en + es initial languages); future jurisdictions extend via additive forward migration.

      • v1 P2 #15 (CSP-report-only requires CSP header in MR1): MR1b’s CSP spike SHIPS the CSP header (in services/canopy-portal/src/main.rs Tower middleware); MR2’s Playwright gate runs against the header set in 1b. Ordering correct.

      • v1 P2 #16 (test budget undersized): §13 acceptance line raised from ≥34 to ≥60 tests (≥15 portal + ≥10 applications + ≥4 notices + ≥3 web + ≥2 common + the rest in canopy-verification + canopy-store).

      • v1 P2 #17 (reviewer-rounds commitment): §17 documents the v1+v2+v3 iteration. Plan 1 lesson learned (~7 reviewer rounds before READY) is internalized via this revision history.

      • v1 P2 #18 (demo runbook page for seeded passcodes): NEW Antora doc page docs/modules/ROOT/pages/runbooks/demo-applicant-credentials.adoc filed as part of MR11.

  • v4: user-quality-bar deep self-audit found 7 gaps v3 reviewer missed. v4 fixes:

    • ApplicantSessionToken shape: spec’d in §2 with full claim set; typed ApplicantClaims struct in canopy-contracts-applications.

    • ADR-025 ref entries expanded: all 4 new tables (application_id_codes, passcode_hashes, application_documents, recovery_pending) get explicit validator entries with cross-service annotations.

    • i18n catalog ownership: Fluent catalogs at services/canopy-portal/locales/\{en,es\}/*.ftl authored per-MR (en with the feature, es consolidated in MR11).

    • Accessibility/WCAG 2.1 AA: axe-core integration in Playwright at MR11; tap targets ≥44×44 px enforced via MR1c CSS; manual screen-reader testing out of automated scope. <html lang="…​"> MUST be set to the active Fluent locale (en/es) — WCAG 2.1 AA SC 3.1.1. Dioxus SSR controls <html>, so MR1b sets lang via the SSR shell / document API (or the same response-rewrite layer that injects the CSP nonce). Keep axe’s html-has-lang/html-lang-valid rules ENABLED if axe sees the SSR lang; disable them ONLY if a Dioxus timing limitation forces it, and ONLY paired with an explicit Playwright assertion that <html> carries a lang matching the negotiated locale (IMTN’s blanket .disableRules(["html-has-lang"]) is NOT adopted).

    • Secrets ownership: Turnstile + Redis URL + applicant session key all in secrets/dev.yaml per ADR-017 (SOPS-encrypted; fake values in repo, real values in deployment-config).

    • Demo timing: pruned to ~4 min Plan 3 contribution (recovery flow built but NOT demoed; kiosk-mode omitted; Spanish demoed only via locale switcher chrome). Combined total: Plan 1 ~3.5 + Plan 2 ~2.5 + Plan 3 ~4 = 10 min.

    • §14 risks expanded: WASM bundle ≤2 MB initial / ≤5 MB per-route gzipped target with Playwright enforcement; brotli serving moved from "Garage CDN" claim (unsupported) to "application-layer brotli precompression + Axum Accept-Encoding middleware"; Turnstile project fit addressed via config-driven CAPTCHA-provider abstraction with hCaptcha fallback.

  • v5: 4th internal reviewer round + 3 cosmetic clarifications (validators path, MR10 TOML schema, fixtures-dir mkdir note, NoopIevsAdapter location).

  • v6: user external review found 8 blockers + 3 P1s that 4 prior internal reviewer rounds missed. All real, all file:line-cited. Pattern matches Plans 1+2: internal reviewers trust pseudocode at the auth/RBAC/persistence boundaries; user catches them. v6 fixes:

    • Blocker #1 (JWT auth wrap): canopy-api ApiServer::router at crates/canopy-api/src/lib.rs:127 wraps every /v1 route in auth middleware. Applicant lookup + recovery cannot live under /v1 on canopy-applications. v6 moves the public endpoints to canopy-portal’s own Axum router (outside canopy-api). canopy-applications gets only the internal service-to-service endpoints (verify-credential, from-applicant-submission).

    • Blocker #2 (ApplicantSessionToken validation): canopy-auth has no signer.rs (verified); JWKS-only validation rejects iss="canopy-applications". v6 switches to opaque session tokens in canopy-portal’s tower-sessions PostgreSQL backend (existing infrastructure). Cross-service calls use service-token + X-Canopy-Actor header per ADR-019 on-behalf-of.

    • Blocker #3 (IDOR): Claims::require_applicant() checks role only. v6 adds applicant-ownership middleware in canopy-portal + handler-level assert_owns_application(&claims, &path_application_id) helper in canopy-auth for canopy-applications endpoints.

    • Blocker #4 (Apply persistence): CreateApplicationRequest requires pre-existing household_id + submitted_by. v6 adds orchestration endpoint POST /v1/applications/from-applicant-submission that creates persons → household → members → income → application in one transaction. canopy-applications adds persons-client wiring as MR6 deliverable.

    • Blocker #5 (draft_payload contradicts §4): v6 replaces applications.draft_payload JSONB extension with NEW application_drafts table (forward-only).

    • Blocker #6 (passcode rotation blocked): table-level UNIQUE (application_id) prevents recovery from inserting a rotated row. v6 removes table-level UNIQUE; only partial unique index governs active-row uniqueness.

    • Blocker #7 (recovery notification source): applications table has no email/phone columns. v6 adds forward migration extending applications with notify_email TEXT + notify_phone_e164 TEXT (nullable; populated by Apply form).

    • Blocker #8 (verification API impossible): Plan 3 needs application_id filter (de-conflict with Plan 2 MR2) + verification_responses table for document references. v6 MR10 adds both.

    • P1 #9 (body limit): canopy-api default is 2 MiB; Plan 3 promises 10 MiB. MR9 overrides ApiServerOpts.body_limit = 10 * 1024 * 1024 in canopy-applications boot.

    • P1 #10 (AppState scanner/store/per-program-bucket): AppState gains scanner: Arc<dyn Scanner> + object_store: Arc<dyn Store>; single Garage bucket with key prefix {program}/{application_id}/{sha256} instead of per-program Store instances (simpler; canopy-store supports it today).

    • P1 #11 (ID collision): MR4 generator wraps INSERT in bounded retry loop on UniqueViolation.

    • P1 #12 (session TTL): §2 says 2 hours, MR5 said 24 hours. v6 locks 2 hours per applicant-portal design ref §3.3.

    • v6 reviewer’s residual passcode_hashes UNIQUE DDL/prose contradiction: §4 DDL line removed UNIQUE keyword from application_id column definition (matches the prose claim that only the partial unique index enforces active-row uniqueness).

  • v7: IMTN reference-app study (2026-05-29) re-grounded the Dioxus specifics against ~/code/imtn (Georgia DHS, the proven 0.7.3 CSP-safe app from ADR-008), verified against the Dioxus 0.7 docs + re-checked by a contextless reviewer. Corrections to the forward spec:

    • Routable syntax: the v2 P0#6 note (“#[layout]` is 0.6; 0.7 uses [nest]”) is wrong and is superseded. In 0.7 both macros exist and do different jobs — `[layout]/[end_layout] group routes under a layout that renders Outlet; [nest] only adds a URL prefix. §9 now carries the real enum (IMTN src/routes.rs:24-78).

    • Bootstrap: replaced LaunchBuilder with the manual dioxus::server::router(App) + tokio runtime — required for strict CSP (the CspNonceLayer must own the Axum response to nonce Dioxus’s inline hydration <script>; the simpler launch would force script-src 'unsafe-inline').

    • Server functions: the architecture diagram’s [server] is the older anonymous form; 0.7 recommends route-attribute macros [get]/#[post]Result<T, ServerFnError> (IMTN src/server/children.rs:11-27).

    • CSP gate (supersedes the open question in P0#5): the script half is proven by IMTN (nonce layer); the style half is the only residual spike (does the framework emit style=?) — fallback stays armed, style: props banned.

    • Deps/toolchain pinned: dioxus = "0.7.3" [fullstack, router] + server-feature gating; wasm32-unknown-unknown; dioxus-cli@0.7.3; dx build; strip = "symbols".

    • IMTN non-donors flagged: reference-number+passcode intimate-threat auth, recovery/kill-switch, Fluent i18n (IMTN is English-only — Dioxus↔Fluent integration is net-new), ELE-consent, multi-instance rate-limit + persistent session secret, X-Canopy-Actor/IDOR remain pure canopy design.

  • v8: privacy-first redesign (2026-05-29) — ratified as ADR-026 + an ADR-009 amendment. canopy-portal becomes a Postgres-free BFF that owns no operator-readable durable state until finalize. This reverses two v6 decisions (which remain above per ADR-013 supersede-in-place): the v6 "opaque session tokens in canopy-portal’s tower-sessions PostgreSQL backend" + dedicated applicant_sessions table → sessions are now Redis-primary opaque tokens (session:{token_hash}) minted at draft-start; and the v6 plaintext application_drafts (which replaced applications.draft_payload) → drafts are now client-side-encrypted JSONB (XChaCha20-Poly1305 under an Argon2id passcode-derived key; server-blind at rest). Changes:

    • Reserved-id lifecycle: the credential + draft are minted at draft-start under a reserved application_id absent from applications until finalize creates the row with that id; application_drafts/application_id_codes/passcode_hashes drop their REFERENCES applications(id) FK; no draft application status (drafts never enter applications).

    • Incremental apply: create-draft/patch-draft/finalize replace the single from-applicant-submission; finalize’s applications-INSERT + draft-DELETE are one transaction (no-orphan), SELECT … FOR UPDATE-serialised against the sliding 30-day reaper.

    • Honest security bound: defeats bulk mining / stolen backups / casual insider access — NOT literal zero-knowledge (~40-bit passcode; server processes plaintext at mint + finalize); resume is conditional on the applicant saving their code.

    • Net-new + prereqs: a noeviction Redis session keyspace (hard MR5 prereq), a best-effort POST /v1/security/audit/ingest on canopy-security (the portal has no broker), and a create_application path that accepts an explicit id.

    • Hardened across five internal-review rounds + the user’s external review (11 findings, incl. the reserved-id lifecycle that resolves the draft-start-credential-FK + finalize-id-reuse blockers) before ratification.

  • v9: MR5 split + MR5a landed (2026-05-30). MR5 was too large for one reviewable MR (3 services + devstack + Keycloak + secrets + the portal vertical), so it splits into 5a (the two backend endpoints — verify-credential on canopy-applications + audit/ingest on canopy-security; the MR1c–MR4 "ship the control with its consumer" discipline) and 5b (the portal /lookup + Redis session minting + screens). 5a is implemented + merged. The two Redis open-decisions are resolved (see Open decisions): the noeviction session keyspace is a separate redis-sessions container — a separate logical DB on the existing instance is not viable because maxmemory-policy is instance-global — and the client is redis-rs. Both land in 5b.

  • v21: MR8c-4 landed (2026-05-31) — the 24h rotate-and-reveal + the kill-switch email-link landing; the recovery flow is COMPLETE. The portal Docker image also moved glibc→musl/Alpine (folded in). When a pending recovery’s 24-hour window has opened (reveal_at < now()), re-passing the App-ID + DOB challenge now rotates the passcode and reveals it once instead of returning Pending again — the server discarded the original at mint (ADR-026), so reveal cannot re-show it; it revokes the active passcode_hashes row and inserts a fresh one in the same transaction that stamps completed_at, so the instant the real applicant reveals, a passcode an intruder captured during the window stops authenticating. New RecoverInitiateOutcome::Revealed{code, passcode} (the passcode rides the HTTP body once, otherwise only a hash; the recovery_completed event is IDs-only). The argon2 hash runs only on the rare ripe path (find_ripe_pending is a cheap SELECT … FOR UPDATE first; complete_reveal does the rotation). canopy-portal gains a one-time reveal screen (reuses the apply flow’s apply-credential styling; full-page anchor drops the passcode from WASM memory; never server-rendered) and a GET /recover/kill/{token} Dioxus landing for the notification’s kill link. Route-collision (re-hit the #659 lesson, caught by the live E2E): the GET landing must be a confirmation (email clients prefetch GET links), but the MR8b kill proxy was mounted at POST /recover/kill/{token} — a POST on that exact path shadows the GET → 405, so the served landing 405’d instead of rendering. Fixed by moving the proxy POST to /recover/kill/{token}/confirm (still under /recover/*, so the rate-limit cascade still covers it); the GET falls through to the Dioxus SPA → KillConfirm, whose explicit button POSTs to the /confirm sub-path. Tests: store rotation (rotated passcode authenticates, original revoked with reason recovery_reveal_rotation) + HTTP integration (initiate → backdate the window → reveal a fresh 12-digit passcode that verifies → a third pass mints a new pending, so reveal is one-shot) + portal map_outcome/map_kill units + reveal/kill SSR strict-CSP gates + a served kill-landing E2E. Folded-in infra change: the canopy-portal Docker image moved from glibc (debian-bookworm-slim) to musl/Alpine (rust:1.94-alpine builder + alpine:3.23 runtime) — the prior "dx + wasm-bindgen + wasm-opt are unreliable on musl/Alpine" justification was an untested assumption from a bookworm-only build spike; the reference IMTN app (ADR-008) builds a full Dioxus 0.7 fullstack on Alpine, and the canopy-portal Alpine build now confirms it (≈51 MB vs ≈163 MB, single lineage, dx … --debug-symbols false kept since the wasm-opt fix is OS-independent). The recovery flow (MR8a → MR8c-4) is now complete end to end: initiate → 24h pending + side-channel notification + kill-switch → reveal/rotate, with the confidential-case block and the oracle-uniform outcomes throughout.

  • v20: MR8c-3 landed (2026-05-31) — the seeded confidential persona + the served-portal recover E2E; closes #634. The demo dataset seeds one fixed applicant-portal persona ("Dana Winters", Application ID HH-c0ffee42, confidentiality = 'confidential') so the recovery flow’s confidential-case block (applicant-portal design ref §3.8) is exercisable against a real case. It is added to the generator (tools/canopy-seed/src/demo/sql_extras.rs), not by hand-editing committed SQL: two appended BEGIN; … COMMIT; supplements (the render_wic_appointments_supplement pattern) emit the household/person/member/address into canopy_persons.sql and the submitted application + SNAP program + the reserved-id credential rows (application_id_codes + an active passcode_hashes) into canopy_applications.sql. Two correctness points: (1) the argon2id passcode_hash is precomputed and embedded as a constant (calling hash_passcode at generation draws a random salt → would break the generator’s byte-stable-output contract), re-verified against the documented passcode by a unit test; (2) reload idempotency — the persons supplement rides the base render_persons TRUNCATE, while the applications supplement TRUNCATEs the credential tables itself because they carry no FK to applications (ADR-026 reserved-id model), so the base applications CASCADE never reaches them (and xtask seed’s `--reset list gains them in lockstep). The constant confidential block was appended to the committed SQL (the rest of the dataset is unchanged — a full demo regenerate would also churn every archetype’s now()-derived dates, an unrelated diff). New tests/e2e/specs/portal-recover.spec.ts + a portal-recover Playwright project walk /recover against the in-network served canopy-portal and assert the ConfidentialBlocked helpline routing (not the pending screen, no passcode/notification leak) plus an oracle-uniform "couldn’t verify" for an unknown Application ID; the project is included only when xtask e2e forwards CANOPY_E2E_SEED_PROFILE=demo (the persona lives in the demo dataset, not the random default seed — -e forwarding mirrors CANOPY_A11Y_AUDIT), so the default e2e run is unchanged. Verified live end-to-end against the served stack (both specs pass; the demo seed loads the persona cleanly + the household resolves cross-DB). MR8c-4 (the 24h rotate-and-reveal screen + the /recover/kill/{token} GET email-link landing) remains.

  • v19: MR8b + MR8c-1 + MR8c-2 landed (2026-05-31); MR8c split into 8c-1/8c-2/8c-3/8c-4. The plan’s MR8 status cell had drifted (it still read "MR8b/8c remain" after MR8b + MR8c-1 merged) — this revision catches it up. MR8b = the portal /recover wizard + the CAPTCHA verifier abstraction shipping noop (real provider deferred #663, no CSP carve-out) + the proxy routes + the per-session 2-wrong RecoverLocked lockout + the safety exit. MR8c was split into four slices because the recovery round-trip spans canopy-applications, canopy-notices, the seed, and the portal reveal UI: 8c-1 = the service-caller-only GET /v1/applicants/recover/{recovery_id} read endpoint (the contact + kill-switch token are read from the recovery_pending row, off the event per ADR-004; OpenAPI 21 → 22); 8c-2 = the canopy-notices recovery subscriber (this) — a dedicated canopy-notices.recovery queue on application.applicant.recovery_initiated, separate from the manifest-driven Typst-notice subscriber because the recovery notice is an email/SMS not a PDF; a new ApplicationsClient (mirrors canopy-applications' persons_client.rs: reqwest + the ADR-019 ServiceTokenSource, per-call token) reads MR8c-1 back, composes the kill-switch link + the 24h reveal time, and delivers via a RecoveryNotificationAdapter logging stub (redacted contact + the full kill link as the demo payload, never the passcode — by construction the RecoveryNotification type has no passcode field; the reveal is the separate 24h-gated 8c-4); idempotency is the event_inbox; the subscriber registers only when the service-token creds are configured (graceful), and canopy-notices gains its OIDC service-account creds + APPLICATIONS_URL + PORTAL_BASE_URL in compose. Verified end-to-end against the live stack (recover/initiate → the event → the stub delivery with the kill link + redacted contact). 8c-3 (the seeded confidential persona + E2E, closing #634) + 8c-4 (the 24h rotate-and-reveal screen + the /recover/kill/{token} GET email-link landing) remain. Design note for 8c-4 (the reveal mechanic): the server discards the passcode at mint (ADR-026), so "reveal" cannot re-show the original — it rotates (revoke the active passcode_hashes row + insert a new one) and returns the new passcode, integrated into the re-challenge: when recover/initiate finds a ripe pending (reveal_at < now, not killed/completed) it rotates + stamps completed_at + returns revealed{code, passcode} instead of re-minting.

  • v18: CAPTCHA re-decision + two doc-contradiction fixes (2026-05-31, post-MR8a, pre-MR8b). (1) CAPTCHA: MR8b now ships only the config-driven verifier abstraction with a noop default; the real provider is deferred to #663. Preferred eventual provider = the org-hosted mCaptcha (proof-of-work) enterprise service (serving canopy + other internal apps) — accessible-by-construction (invisible PoW, no image/typing) + self-hosted (nothing about a recovery attempt leaves org infra, a privacy gain for the intimate-threat flow); fallback hCaptcha/Turnstile; never reCAPTCHA. The CSP impact is provider-derived (each provider declares its origins; noop → strict CSP unchanged; self-hosted mCaptcha → 'self'/internal-origin at most) — so MR8b adds NO Cloudflare CSP carve-out, and no provider/SOPS secret ships with MR8b. The rate-limit cascade is the live recovery-abuse control until #663 lands. Reconciled across the MR7/MR8 rows, the CAPTCHA-provider + Secrets-ownership decision rows, the §Rate-limiting-cascade note, the architecture box, and the §References ADR-017 annotation. (2) Doc-contradiction fixes surfaced by a pre-MR8b consistency audit: the threat-model row said "Required entry: App-ID + letter-NTC (NOT DOB)" — a direct inversion of applicant-portal design ref §3.7 + the shipped MR8a contract (which enforce App-ID + DOB, with the letter-NTC/year as accepted-but-unverified friction, #662) — corrected; and the ApplicantRecoveryInitiatedEvent design sketch still inlined the kill_switch_token + initiator IP — corrected to match the shipped events (IDs + reveal_at only; the token + contact are row-only secrets read by canopy-notices in MR8c). Also added the missing applications.confidentiality/recovery_locked gate-columns DDL block (was prose-only). Open (escalated to the user/design team, not silently changed): the applicant-portal design ref §3.6 provider recommendation (Turnstile/hCaptcha) vs. the new mCaptcha preference, the §3.6 "CAPTCHA on reveal" wording vs. the as-built initiate placement, and whether the recovery/confidentiality model should get an ADR home (ADR-026 is cited but does not contain it).

  • v17: MR8a landed (2026-05-31) — the lost-credential recovery backend + the #634 confidentiality flag; MR8 split into 8a/8b/8c. The recovery flow is a safety-critical intimate-threat defense (applicant-portal design ref §3.4-3.8), and #634 (the confidentiality flag that disables self-serve recovery for protected cases) was an explicit hard prerequisite — so 8a delivers both: recovery_pending + the applications.confidentiality/recovery_locked gate columns + canopy-reference::Confidentiality + store::recovery + PersonsClient::get_person + POST /v1/applicants/recover/{initiate,kill/{token}} + the recovery-pruner scheduler tick + three PII-allowlist outbox events. The original pre-emptive split put the Turnstile verifier in 8a; it moves to 8b (the portal) because its consumer is the public /recover completion — the same "build the control where its consumer is" principle that re-sequenced Turnstile MR7→MR8, applied one level down. So: 8a = backend + #634 gate (this); 8b = portal /recover wizard + Turnstile verifier + CSP carve-out; 8c = notices subscriber + reveal screen + seeded confidential persona + E2E (closes #634). The recovery security boundary is the App-ID gate + DOB second factor + confidential block + 24h delayed reveal + kill-switch + side-channel notification + the MR7 rate-limit cascade — NOT the challenge answers (applicant-portal design ref §3.7 says so explicitly). The initiate endpoint always returns 200 with the outcome in the body so it is not a case-enumeration oracle (unknown-code == wrong-DOB == challenge_failed; confidential_blocked is the §3.8-accepted disclosure routing protected cases to the helpline). Design deviations (ADR-013, recorded in the MR8 status cell): the kill-switch token is kept OFF the recovery_initiated event (MR8c reads it from the row — capability secrets don’t ride the broadcast bus); the case-lock is a recovery_locked boolean not a status; the recent_letter_id/approx_decision_year friction challenges are accepted but not yet server-verified (additive, tracked — the App-ID + DOB gate is the boundary). Migrations 20260604000000 (gate columns) + 20260604000001 (recovery_pending), monotonic after MR6’s 20260603*.

  • v16: MR7 landed (2026-05-31) — the applicant-portal rate-limit cascade; the Turnstile gate re-sequenced to MR8. The architecturally-correct decomposition (user steer: "architecturally correct, no shortcuts"): a rate-limiter is an always-on, cross-cutting control that belongs as middleware on every applicant write endpoint and is fully buildable now; a CAPTCHA is a step-up challenge on one high-risk action (recovery completion), so it belongs at that handler — which is MR8. Building the CAPTCHA verifier in MR7 would be dead code (the bin-crate -D warnings constraint that drove MR4→MR6), and bolting it onto /lookup would attach the control to the wrong endpoint. So MR7 = the cascade (src/ratelimit.rs), MR8 absorbs the Turnstile gate (its row + the §Rate-limiting-cascade design note updated). As built: a fail-open RateLimiter enum (Redis\|Disabled) over the noeviction redis-sessions keyspace; a from_fn middleware runs the device-cookie (5/hr, 10/day) + CGNAT-aware IP (300/3000/hr) tiers → uniform 429`Retry-After`; the per-CaseID "8 wrong/day" cap runs in the `/lookup` handler (check-before-verify / record-on-miss / clear-on-success); atomic `INCR`EXPIRE Lua counters. The plan’s RateLimitBackend trait sketch was realised as the enum (no async_trait/dyn; the portal stays dependency-light); the limits match the sketch. New workspace dep ipnetwork; redis += script feature. No secrets added (limits aren’t secret — Turnstile/SOPS is MR8).

  • v15: MR6e landed (2026-05-31) — Apply submit: /apply/finalize proxy + one-time credential reveal. The capstone of MR6 — the visible apply→submit flow now works end-to-end. Portal POST /apply/finalize reads the Apply session → reserved id and forwards the client’s FinalizeRequest to canopy-applications finalize with the service token (id from the server-trusted session, never the body). The review Submit is wired (gated since MR6b): the client builds the request from its in-memory plaintext and advances to a terminal screen that reveals the Application ID + passcode once, client-only (never SSR — step starts at 0 server-side), with full-page <a href> CTAs so leaving clears the passcode from WASM memory. Design note (resolved a real gap): the MR6b wizard’s DraftData couldn’t produce a valid FinalizeRequestFinalizePerson/FinalizeHouseholdMember need split names + a real NaiveDate DOB, and the wizard had a single name field, a free-text DOB, and no member DOB at all. Fabricating eligibility-relevant DOBs is unacceptable (ADR-026), so MR6e extends the wizard to collect first/last name + a native date-input DOB for the applicant and each household member — "ship the control with its consumer." Honest mapping (all explicit, no fabrication): income = [] (the wizard captures categories, not verifiable amounts — a worker records real income later), programs_requested = ["snap"] (UAT program; multi-program selection is a later enhancement), ele_consent = false (never assumed), notify_email carried, phone best-effort US E.164 or omitted. No OpenAPI change (the finalize endpoint is MR6c’s; canopy-portal has no JSON-API surface).

  • v14: MR6d landed (2026-05-31) — the sliding 30-day draft reaper. canopy-applications' first background scheduler (src/scheduler.rs), reusing the canopy-renewals / canopy-medicaid run_with_advisory_lock leader-election (#428) verbatim. store::drafts::reap_expired_drafts does the sweep in one transaction: SELECT … FOR UPDATE SKIP LOCKED over WHERE expires_at < now() (the predicate is the in-tx expiry re-check, evaluated under the lock — a draft a concurrent patch slid forward is never reaped), then explicit ordered deletes passcode_hashesapplication_id_codesapplication_drafts (NOT ON DELETE CASCADE — finalize must delete the draft while keeping the credentials, so the cascade direction is deliberately absent). SKIP LOCKED is the finalize serialisation: a draft whose finalize holds the row lock is skipped this sweep (finalize keeps the credentials), so the reaper can never strand a submitted application’s login. The daily timer is paired with an on-demand admin trigger POST /v1/applicants/drafts/reap (service-caller only — operator tooling, not applicant-reachable) returning ReapDraftsResponse { drafts_reaped }, which also makes the otherwise time-driven sweep HTTP-testable; the route is a static sibling of …/drafts/{id} (matchit 0.8 prioritises the static segment; methods differ). OpenAPI 18 → 19.

  • v13: MR6c landed (2026-05-31) + "6c" split into 6c/6d/6e. The planned "MR6c" (finalize + persons-client + reaper + ele-consent) is split the way 6 split into 6a/6b/6c: 6c = the finalize backend (this), 6d = the sliding reaper, 6e = the portal /apply/finalize proxy + Submitted screen. Finalize is canopy-applications' first outbound service-to-service client; the canopy-applications Keycloak service-account client already existed, so only env creds + PERSONS_URL were added (no realm change). The persons client is built graceful (Option) so the service still boots without creds. ELE consent is recorded by emitting the event directly in-transaction (conditional on the ele_consent flag), not a self-HTTP-call to the worker record_ele_consent route. The cross-service persons writes are outside the local tx (ADR-026 §5), with an early non-locking draft_exists check to avoid orphaning persons for an already-gone draft; the in-tx FOR UPDATE lock remains authoritative. In the forward flow the client submits its in-memory plaintext, so finalize needs no server-side decrypt / get-draft (resume stays a later MR).

  • v12: MR6b landed (2026-05-30) — the Apply form + client crypto. The novel pieces were de-risked first: cargo build --target wasm32-unknown-unknown proved argon2 + chacha20poly1305 compile clean on wasm32 with default-features = false (sidestepping the rand-0.9 → getrandom-0.3 wasm_js-backend complication — the AEAD nonce comes from web-sys crypto.getRandomValues, the Argon2id salt from the server, so neither crate needs an RNG). The client→server call is a hand-rolled CSP-clean web-sys fetch (connect-src 'self' already permitted; the portal stays reqwest-free on the client). The /apply/start + /apply/save proxy routes reuse MR5b’s lookup.rs deps + cookie helpers (made pub(crate)); the session is minted portal-side (the portal owns Redis), so it belongs to the proxy route, not the canopy-applications endpoint. Scope held to the forward flow: resume needs a get-draft endpoint (later) and finalize/credential-reveal are MR6c, so Submit is gated. decrypt + the Decrypt variant are #[cfg_attr(not(test), allow(dead_code))] (only the round-trip test exercises them until the resume MR adds the consumer). Production dx build --release emits zero inline styles (the wasm-opt SIGABRT is the known non-fatal DWARF issue, #659).

  • v11: MR6 split 6a/6b/6c + MR6a landed (2026-05-30). MR6 is refined from the planned 2-way (6a form+create-draft/patch+crypto, 6b finalize+reaper) into a 3-way split mirroring MR5’s 5a/5b: 6a = the canopy-applications drafts backend (application_drafts migration + store::drafts::{mint_draft, patch_draft} + the service-caller create-draft / patch-draft endpoints), 6b = the portal Apply form + WASM client crypto + the /apply/ proxy routes + the initial FlowKind::Apply Redis session mint, 6c = finalize orchestration (explicit-id create + persons-client + one-transaction draft-DELETE) + the reaper + the ele-consent call. Rationale: MR6a’s endpoints are independently HTTP-testable and "ship the control before its consumer" (the MR5a/MR5b discipline), and the session mint is portal-side (the portal owns Redis), so it naturally belongs to 6b, not the canopy-applications endpoint. MR6a delivered the store-layer mint + 10-attempt collision-retry the MR4 generator deferred to its live-DB caller. Migration timestamp is the real 20260603000000 (monotonic after MR4’s 20260602; the §4/§6 20260615* placeholders are superseded by the actual merge-time stamp per the v8 convention note).

  • v10: MR5b landed (2026-05-30) — kept UNIFIED. The proposed 5b-1/5b-2 sub-split was abandoned mid-build: the session store’s only non-test consumer is /lookup, and canopy-portal is bin-only, so a store-without-/lookup slice is dead_code under -D warnings (the exact MR4 store-mint rationale, now applied to the portal) — infra + store + service-token + the three routes + the screen ship together. The canopy-portal Keycloak service client turned out to already exist in canopy-realm.json (no realm/SOPS work, no port-reconcile re-entanglement). The portal authenticates through itself with an ADR-019 client_credentials token; the lookup routes are plain Axum mounted before the Dioxus greedy fallback (which made per-route auth middleware on authed Dioxus pages impractical — hence the explicit GET /me / POST /logout rather than SSR session injection, deferred to when authed pages render real per-applicant data). A live end-to-end served-portal test belongs to #659; the axum-oneshot flow test + the MR5a endpoint tests cover the contract until then.

Lessons learned (subagents miss structural claims about JWT/RBAC/migrations; user-style verification catches them; ground every claim in direct file:line repo reads) carry forward as feedback_plan_quality_bar precedent. Full provenance lives in the meta-plan handoff at ~/.claude/projects/-home-bitskrieg-code-canopy/memory/project_demo_video_3plan_handoff.md.

Edit this page · default