Plan: Applicant intake + verification (Dioxus 0.7+ canopy-portal)
On this page
- Status
- Context
- Locked decisions
- Open decisions
- Architecture
- Schema additions
<MR_MERGE_DATE>_create_application_id_codes.sql(MR4)<MR_MERGE_DATE>_create_passcode_hashes.sql(MR4)<MR_MERGE_DATE>_extend_applications_notify_columns.sql(MR4)20260603000000_create_application_drafts.sql(MR6a)<MR_MERGE_DATE>_extend_applications_recovery_gate.sql(MR8a, #634)<MR_MERGE_DATE>_create_recovery_pending.sql(MR8)<MR_MERGE_DATE>_create_application_documents.sql(MR9)<MR_MERGE_DATE>_create_verification_responses.sql(MR10, canopy-verification)
- Rate-limiting cascade (canopy-portal/src/ratelimit.rs)
- Credential generation (canopy-applications/src/credentials/)
- Outbox events (PII-allowlist per ADR-014)
- canopy-portal Dioxus 0.7+ rewrite (MR1b + MR2 + MR3)
- RBAC (extending Plan 2’s new Claims guard)
- Demo script (Plan 3 contribution to the 10-min combined video)
- Verification
- Risks & mitigations
- References
- Out of scope (deferred to post-Plan-3 follow-ups)
- Revision history
Status
| MR | Description | Status |
|---|---|---|
1a |
|
Done (2026-05-29) — ADR-008 gains an |
1b |
|
Done (2026-05-29) — Dioxus 0.7.9 fullstack scaffold landed: |
1c |
|
Done (2026-05-30) — Orchard design-system foundation ported into |
2 |
|
Done (2026-05-30) — |
3 |
|
Done (2026-05-30) — |
4 |
|
Done (2026-05-30) — |
5a |
|
Done (2026-05-30) — verify-credential (canopy-applications): |
5b |
|
Done (2026-05-30) — Kept UNIFIED (not sub-split): the session store’s only non-test consumer is |
6a |
|
Done (2026-05-30) — |
6b |
|
Done (2026-05-30) — |
6c |
|
Done (2026-05-31) — |
6d |
|
Done (2026-05-31) — |
6e |
|
Done (2026-05-31) — |
7 |
|
Done (2026-05-31) — the rate-limit cascade ( |
8 |
|
Done (2026-05-31) — Closes #634 — MR8a done (2026-05-31): the canopy-applications recovery backend + the #634 confidentiality data model + gate. New |
9 |
|
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 |
10 |
|
Done (2026-06-01) — split into 10a (canopy-verification scripted IEVS/SAVE adapters + fixtures + config), 10b ( |
11 |
|
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 |
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-evalis 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.
Locked decisions
| Decision | Choice |
|---|---|
Reference number format |
HANDOFF wins: |
CSP strictness |
|
Public endpoint hosting |
|
|
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 |
RBAC / IDOR protection |
|
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 |
Credential storage |
Privacy-first redesign — ADR-026. The credential is minted server-side at draft-start (Model S), keyed on the reserved |
ID collision retry |
32-bit hex codes have birthday collisions around 65K applications. MR4 generator wraps INSERT in bounded retry loop (10 attempts) on |
Applications-table extension |
Plan 3 MR4 adds forward migration extending |
Draft persistence |
Privacy-first redesign — ADR-026. NEW |
Apply form orchestration |
Privacy-first redesign — ADR-026. Incremental, not single-shot: |
Document upload |
NEW |
Verification surface extension |
MR10 adds: (a) |
IEVS/SAVE scripted adapters |
TOML configs at |
Rate-limiting cascade |
Redis-backed (existing devstack — Redis service at repo-root |
CAPTCHA provider |
Re-decided 2026-05-31 — MR8b ships |
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 ( |
ADR-025 cross-service refs |
Registered in |
i18n catalogs |
Fluent at |
Accessibility (WCAG 2.1 AA) |
Per ADR-008 §181-214 + applicant-portal design ref §10. axe-core integration in MR11 Playwright |
Secrets ownership |
Redis URL credentials + the |
Shared-deliverable de-conflict |
|
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 |
Open decisions
Resolved 2026-05-28 (post-implementation-readiness re-verification — see the demo readiness assessment):
-
Session-store shape — RESOLVED: a dedicated
applicant_sessionstable (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_sessionsPostgreSQL 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-sessionsdevstack container (maxmemory-policy noeviction,appendonly yes), NOT a separate logical DB on the existingredisinstance: Redismaxmemory-policyis instance-global, so a second DB on theallkeys-lrucache instance would still evict live sessions — the "separate logical DB" option floated earlier is not actually viable. The cache instance keepsallkeys-lru; the two eviction policies stay isolated. Client =redis(redis-rs) withredis::aio::ConnectionManager(mature, conventional, light for a hand-rolledSET EX/GET/DELtoken store), NOTfred. Both land in MR5b. -
document_typevsDocumentKind— RESOLVED: orthogonal axes.document_typeis the purpose (the SAME closed set as canopy-verification’sverification_type, which owns it per ADR-001);document_kindis the optional form metadata (HANDOFF DocumentKind). No third parallel enum — see theapplication_documentsschema. -
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 fallback — RESOLVED (MR1b, 2026-05-29): the spike PASSED on Dioxus 0.7.9. The release build’s SSR +
index.htmlemit zero inlinestyle=/<style>, and theCspNonceLayernonces every<script>— so strictstyle-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-devdx serveworkflow 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::routerbootstrap),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 buildgzip-size check. -
Asset serving + compression: the static stylesheet is served via Dioxus’s compile-time
asset!()macro (IMTN-proven —imtn/src/app.rs:9-13declares the assets,:58-66renders them viadocument::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.brand canopy-portal’s Axum serves it via anAccept-Encoding: brmiddleware. (Net-new vs IMTN, which does no brotli at all — flag as an unproven-against-IMTN bandwidth optimization; drop it if it complicates thedx/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
noopdefault 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 fornoop). -
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/startsub-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_e164snapshot 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_passcodeflow 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
-
ADR-008: Applicant Portal Architecture (MR1a files amendments to §82 + §214)
-
ADR-014: FTI Audit Hash-Chain Integrity (PII gate on events)
-
ADR-017: Encrypted Secrets at Rest (Redis + encrypted secrets; a CAPTCHA-provider secret, if the eventual provider needs one, is added at integration time per #663 — none ships with MR8b’s
noop) -
ADR-019: Service Identity and On-Behalf-Of (X-Canopy-Actor pattern)
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 viadioxus::server::router(App)in MR1b (NOTLaunchBuilder— 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,240—require_applicant,require_service_or_caseworker_or_above(existing); Plan 3 addsassert_owns_applicationhelper -
crates/canopy-auth/src/client_ext.rs:33—ACTOR_HEADERconstant 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.rs—validate_uploadreused 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
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/mainat branch-off time + declares OR skips each artifact as appropriate; no double-declarations. -
P0 #3 (devstack path): corrected
devstack/docker-compose.yml→ repo-rootdocker-compose.yml:78-85. -
P0 #4 (canopy-applications scheduler doesn’t exist): MR8 now explicitly introduces
services/canopy-applications/src/scheduler.rsmirroring 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]withOutletrendering. §9 sample updated. -
P0 #9 (lookup endpoint ownership):
POST /v1/applicants/lookupmoves to canopy-portal (rate-limit cascade lives there); canopy-applications gets internalPOST /v1/applicants/verify-credentialinstead. 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 existingapplication.submittedat events.rs:15. -
P1 #12 (port number uncited): §13 verification block now derives
PORTAL_PORTfrom.ports.envinstead 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_PORTAL→CANOPY_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-85→77-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 (
:08xlowercase vs "uppercase" prose): §6 generator usesformat!("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.rsTower 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.adocfiled as part of MR11.
-
-
-
v4: user-quality-bar deep self-audit found 7 gaps v3 reviewer missed. v4 fixes:
-
ApplicantSessionTokenshape: spec’d in §2 with full claim set; typedApplicantClaimsstruct 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\}/*.ftlauthored 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 setslangvia the SSR shell / document API (or the same response-rewrite layer that injects the CSP nonce). Keep axe’shtml-has-lang/html-lang-validrules ENABLED if axe sees the SSRlang; disable them ONLY if a Dioxus timing limitation forces it, and ONLY paired with an explicit Playwright assertion that<html>carries alangmatching 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.yamlper 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::routeratcrates/canopy-api/src/lib.rs:127wraps every/v1route in auth middleware. Applicant lookup + recovery cannot live under/v1on 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 rejectsiss="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-Actorheader per ADR-019 on-behalf-of. -
Blocker #3 (IDOR):
Claims::require_applicant()checks role only. v6 adds applicant-ownership middleware in canopy-portal + handler-levelassert_owns_application(&claims, &path_application_id)helper in canopy-auth for canopy-applications endpoints. -
Blocker #4 (Apply persistence):
CreateApplicationRequestrequires pre-existinghousehold_id+submitted_by. v6 adds orchestration endpointPOST /v1/applications/from-applicant-submissionthat 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 JSONBextension with NEWapplication_draftstable (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_idfilter (de-conflict with Plan 2 MR2) +verification_responsestable 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 * 1024in 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 UNIQUEDDL/prose contradiction: §4 DDL line removedUNIQUEkeyword fromapplication_idcolumn 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 rendersOutlet;[nest]only adds a URL prefix. §9 now carries the real enum (IMTNsrc/routes.rs:24-78). -
Bootstrap: replaced
LaunchBuilderwith the manualdioxus::server::router(App)+ tokio runtime — required for strict CSP (theCspNonceLayermust own the Axum response to nonce Dioxus’s inline hydration<script>; the simpler launch would forcescript-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>(IMTNsrc/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_sessionstable → sessions are now Redis-primary opaque tokens (session:{token_hash}) minted at draft-start; and the v6 plaintextapplication_drafts(which replacedapplications.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_idabsent fromapplicationsuntilfinalizecreates the row with that id;application_drafts/application_id_codes/passcode_hashesdrop theirREFERENCES applications(id)FK; nodraftapplication status (drafts never enterapplications). -
Incremental apply:
create-draft/patch-draft/finalizereplace the singlefrom-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/ingeston canopy-security (the portal has no broker), and acreate_applicationpath 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-credentialon canopy-applications +audit/ingeston 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 separateredis-sessionscontainer — a separate logical DB on the existing instance is not viable becausemaxmemory-policyis 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 returningPendingagain — the server discarded the original at mint (ADR-026), so reveal cannot re-show it; it revokes the activepasscode_hashesrow and inserts a fresh one in the same transaction that stampscompleted_at, so the instant the real applicant reveals, a passcode an intruder captured during the window stops authenticating. NewRecoverInitiateOutcome::Revealed{code, passcode}(the passcode rides the HTTP body once, otherwise only a hash; therecovery_completedevent is IDs-only). The argon2 hash runs only on the rare ripe path (find_ripe_pendingis a cheapSELECT … FOR UPDATEfirst;complete_revealdoes the rotation). canopy-portal gains a one-time reveal screen (reuses the apply flow’sapply-credentialstyling; full-page anchor drops the passcode from WASM memory; never server-rendered) and aGET /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 atPOST /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/confirmsub-path. Tests: store rotation (rotated passcode authenticates, original revoked with reasonrecovery_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) + portalmap_outcome/map_killunits + 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-alpinebuilder +alpine:3.23runtime) — 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 falsekept 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 appendedBEGIN; … COMMIT;supplements (therender_wic_appointments_supplementpattern) emit the household/person/member/address intocanopy_persons.sqland the submitted application + SNAP program + the reserved-id credential rows (application_id_codes+ an activepasscode_hashes) intocanopy_applications.sql. Two correctness points: (1) the argon2idpasscode_hashis precomputed and embedded as a constant (callinghash_passcodeat 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 baserender_personsTRUNCATE, while the applications supplement TRUNCATEs the credential tables itself because they carry no FK toapplications(ADR-026 reserved-id model), so the baseapplicationsCASCADE never reaches them (andxtask seed’s `--resetlist gains them in lockstep). The constant confidential block was appended to the committed SQL (the rest of the dataset is unchanged — a fulldemo regeneratewould also churn every archetype’snow()-derived dates, an unrelated diff). Newtests/e2e/specs/portal-recover.spec.ts+ aportal-recoverPlaywright project walk/recoveragainst the in-network servedcanopy-portaland 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 whenxtask e2eforwardsCANOPY_E2E_SEED_PROFILE=demo(the persona lives in the demo dataset, not the random default seed —-eforwarding mirrorsCANOPY_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
/recoverwizard + the CAPTCHA verifier abstraction shippingnoop(real provider deferred #663, no CSP carve-out) + the proxy routes + the per-session 2-wrongRecoverLockedlockout + 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-onlyGET /v1/applicants/recover/{recovery_id}read endpoint (the contact + kill-switch token are read from therecovery_pendingrow, off the event per ADR-004; OpenAPI 21 → 22); 8c-2 = the canopy-notices recovery subscriber (this) — a dedicatedcanopy-notices.recoveryqueue onapplication.applicant.recovery_initiated, separate from the manifest-driven Typst-notice subscriber because the recovery notice is an email/SMS not a PDF; a newApplicationsClient(mirrors canopy-applications'persons_client.rs: reqwest + the ADR-019ServiceTokenSource, per-call token) reads MR8c-1 back, composes the kill-switch link + the 24h reveal time, and delivers via aRecoveryNotificationAdapterlogging stub (redacted contact + the full kill link as the demo payload, never the passcode — by construction theRecoveryNotificationtype has no passcode field; the reveal is the separate 24h-gated 8c-4); idempotency is theevent_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_URLin 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 activepasscode_hashesrow + insert a new one) and returns the new passcode, integrated into the re-challenge: whenrecover/initiatefinds a ripe pending (reveal_at < now, not killed/completed) it rotates + stampscompleted_at+ returnsrevealed{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
noopdefault; 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 theApplicantRecoveryInitiatedEventdesign sketch still inlined the kill_switch_token + initiator IP — corrected to match the shipped events (IDs +reveal_atonly; the token + contact are row-only secrets read by canopy-notices in MR8c). Also added the missingapplications.confidentiality/recovery_lockedgate-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+ theapplications.confidentiality/recovery_lockedgate columns +canopy-reference::Confidentiality+store::recovery+PersonsClient::get_person+POST /v1/applicants/recover/{initiate,kill/{token}}+ therecovery-prunerscheduler 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/recovercompletion — 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/recoverwizard + 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_blockedis 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 therecovery_initiatedevent (MR8c reads it from the row — capability secrets don’t ride the broadcast bus); the case-lock is arecovery_lockedboolean not a status; therecent_letter_id/approx_decision_yearfriction challenges are accepted but not yet server-verified (additive, tracked — the App-ID + DOB gate is the boundary). Migrations20260604000000(gate columns) +20260604000001(recovery_pending), monotonic after MR6’s20260603*. -
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 warningsconstraint that drove MR4→MR6), and bolting it onto/lookupwould 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-openRateLimiterenum (Redis\|Disabled) over the noevictionredis-sessionskeyspace; afrom_fnmiddleware runs the device-cookie (5/hr, 10/day) + CGNAT-aware IP (300/3000/hr) tiers → uniform429`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`EXPIRELua counters. The plan’sRateLimitBackendtrait sketch was realised as the enum (noasync_trait/dyn; the portal stays dependency-light); the limits match the sketch. New workspace depipnetwork;redis+=scriptfeature. No secrets added (limits aren’t secret — Turnstile/SOPS is MR8). -
v15: MR6e landed (2026-05-31) — Apply submit:
/apply/finalizeproxy + one-time credential reveal. The capstone of MR6 — the visible apply→submit flow now works end-to-end. PortalPOST /apply/finalizereads the Apply session → reserved id and forwards the client’sFinalizeRequestto canopy-applicationsfinalizewith 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 —stepstarts 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’sDraftDatacouldn’t produce a validFinalizeRequest—FinalizePerson/FinalizeHouseholdMemberneed split names + a realNaiveDateDOB, 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_emailcarried, 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-medicaidrun_with_advisory_lockleader-election (#428) verbatim.store::drafts::reap_expired_draftsdoes the sweep in one transaction:SELECT … FOR UPDATE SKIP LOCKEDoverWHERE 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 deletespasscode_hashes→application_id_codes→application_drafts(NOTON DELETE CASCADE— finalize must delete the draft while keeping the credentials, so the cascade direction is deliberately absent).SKIP LOCKEDis 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 triggerPOST /v1/applicants/drafts/reap(service-caller only — operator tooling, not applicant-reachable) returningReapDraftsResponse { 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/finalizeproxy + Submitted screen. Finalize is canopy-applications' first outbound service-to-service client; thecanopy-applicationsKeycloak service-account client already existed, so only env creds +PERSONS_URLwere 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 theele_consentflag), not a self-HTTP-call to the workerrecord_ele_consentroute. The cross-service persons writes are outside the local tx (ADR-026 §5), with an early non-lockingdraft_existscheck to avoid orphaning persons for an already-gone draft; the in-txFOR UPDATElock 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-unknownprovedargon2+chacha20poly1305compile clean on wasm32 withdefault-features = false(sidestepping the rand-0.9 → getrandom-0.3wasm_js-backend complication — the AEAD nonce comes from web-syscrypto.getRandomValues, the Argon2id salt from the server, so neither crate needs an RNG). The client→server call is a hand-rolled CSP-clean web-sysfetch(connect-src 'self'already permitted; the portal stays reqwest-free on the client). The/apply/start+/apply/saveproxy routes reuse MR5b’slookup.rsdeps + cookie helpers (madepub(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+ theDecryptvariant are#[cfg_attr(not(test), allow(dead_code))](only the round-trip test exercises them until the resume MR adds the consumer). Productiondx build --releaseemits 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_draftsmigration +store::drafts::{mint_draft, patch_draft}+ the service-callercreate-draft/patch-draftendpoints), 6b = the portal Apply form + WASM client crypto + the/apply/proxy routes + the initialFlowKind::ApplyRedis session mint, 6c =finalizeorchestration (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 real20260603000000(monotonic after MR4’s20260602; the §4/§620260615*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-/lookupslice isdead_codeunder-D warnings(the exact MR4 store-mint rationale, now applied to the portal) — infra + store + service-token + the three routes + the screen ship together. Thecanopy-portalKeycloak service client turned out to already exist incanopy-realm.json(no realm/SOPS work, no port-reconcile re-entanglement). The portal authenticates through itself with an ADR-019client_credentialstoken; 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 explicitGET /me/POST /logoutrather 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-oneshotflow 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.