ADR-008: Applicant Portal Architecture (Dioxus Fullstack)

On this page

Status

Accepted — 2026-03-30

Context

Canopy has two user-facing portals: canopy-web (worker portal, Askama + htmx + Alpine.js) and canopy-portal (applicant portal, currently a session-only stub). These portals serve fundamentally different audiences with different interaction patterns.

Workers interact with the system through data-dense tables, search results, and tabbed case views. They work on fast agency networks with modern browsers. Askama + htmx is the right fit: server-rendered tables, minimal JavaScript, fast response times.

Applicants interact through guided multi-step forms, document uploads, save-and-resume workflows, and status dashboards. They may be on metered cellular data, old Android phones, public library computers, or DHS field office kiosks. Many have disabilities or limited English proficiency. The portal must work reliably across all of these conditions while meeting Section 508 (WCAG 2.1 AA) requirements.

The Georgia DHS "It’s My Turn Now" (IMTN) foster care adoption portal — built by the same organization — has proven that Dioxus 0.7 fullstack can deliver WCAG 2.1 AA compliance in a government context. IMTN ships 279 E2E tests including axe-core audits with zero violations, uses the same Georgia Orchard design system, and runs as a single Rust binary with SSR + WASM hydration. That proof of concept eliminates the primary risk of adopting Dioxus for canopy-portal.

Several architectural questions about the applicant portal have been deferred since project inception:

  1. Session model — anonymous application start vs. required account creation

  2. Authentication — Keycloak account vs. reference-number-based access

  3. i18n — locale negotiation strategy for multi-language support

  4. Document upload — file restrictions, virus scanning, PII handling

  5. Accessibility scope — assistive technology targets

  6. Multi-program flow — single-streamlined (ACA §1413) vs. per-program forms

  7. Platform targets — web-only vs. kiosk vs. mobile

This ADR resolves all seven.

Decision

canopy-portal is rebuilt as a Dioxus 0.7+ fullstack application: server-side rendering for initial page loads, WASM hydration for client-side interactivity, and server functions for API calls. It replaces the current Askama + tower-sessions stub entirely.

Architecture

Browser Request
      │
      ▼
canopy-portal binary (single Rust binary)
      │
      ├── Static assets → ServeDir (CSS, WASM, fonts, images)
      ├── /healthz → 200 OK
      ├── /api/* → Dioxus server functions (proxied to Canopy services)
      └── /* → Dioxus SSR
              │
              ▼
         HTML Response (server-rendered, with hydration data)
              │
              ▼
         WASM Hydration (async, non-blocking)
              │
              ▼
         SPA Navigation (subsequent pages, no full reloads)

The portal is a BFF (Backend for Frontend) that:

  • Renders HTML via Dioxus SSR (fast first paint, works before WASM loads)

  • Hydrates with WASM for client-side interactivity (form validation, save-and-resume, theme toggle)

  • Calls Canopy service APIs via server functions (canopy-persons, canopy-applications, canopy-eligibility, canopy-snap, canopy-appeals, canopy-notices)

  • Never accesses databases directly — all data flows through service APIs per ADR-001

Session Model

Applicants begin with an anonymous session. No account creation required to start an application. The session is PostgreSQL-backed (tower-sessions-sqlx-store, never MemoryStore) with a 30-minute sliding TTL.

On form submission, the system generates a reference number (format: CANOPY-YYYYMMDD-XXXXXXXX). The applicant receives this number via the portal and, if provided, via email or SMS. The reference number + date of birth serves as the authentication pair for returning to check status, upload documents, or file appeals.

NOTE

Superseded by Amendment 1 — Credential format (2026-05-29). The reference-number format and the reference-number-plus-date-of-birth authentication pair described in this section are no longer in effect. The authentication pair is now an Application ID (HH-[a-f0-9]{8}) plus a generated Passcode (word-word-word-NN); date of birth is never used as an authentication factor.

NOTE
The passcode format is superseded by Amendment 3 — Passcode format: all-digit (now NNNN-NNNN-NNNN); see the Amendments section.

No Keycloak account is created for applicants. Keycloak is reserved for workers and system operators. Applicant identity verification happens through the eligibility determination process, not through an identity provider.

Rationale: requiring account creation is a barrier. Many SNAP applicants lack stable email addresses. The reference-number model mirrors how paper applications work today — the applicant receives a case number and calls to check status.

Authentication

Action Authentication

Start new application

None (anonymous session)

Save draft and receive reference number

None (reference number generated on first save)

Resume application

Reference number + date of birth

Check determination status

Reference number + date of birth

Upload documents

Reference number + date of birth

View notices

Reference number + date of birth

File appeal

Reference number + date of birth

Admin/worker access to portal

Not available — workers use canopy-web

Reference number + DOB verification is handled by a dedicated server function that validates against canopy-applications. Failed attempts are rate-limited (30 per 60 seconds per client, matching IMTN’s pattern).

Internationalization (i18n)

The portal uses Project Fluent (.ftl files) for all user-visible strings. No English text is hardcoded in Rust source or templates.

Locale negotiation (in priority order):

  1. Explicit URL path prefix: /es/apply, /en/apply

  2. User selection stored in session/localStorage

  3. Accept-Language header from browser

  4. Default: English (en)

Initial languages: English (en) and Spanish (es). Georgia’s SNAP population is approximately 8% Spanish-speaking. Additional languages added by creating new .ftl files — no code changes required.

SSR i18n: Server functions resolve the locale from the request context and load the appropriate Fluent bundle. Translated strings are embedded in the SSR HTML. WASM hydration picks up the same locale from a <meta> tag or data- attribute.

Fluent file organization:

locales/
  en/
    common.ftl          # Navigation, buttons, errors
    apply.ftl           # Application form labels and help text
    status.ftl          # Status dashboard
    notices.ftl         # Notice viewer
    appeals.ftl         # Appeal filing
  es/
    common.ftl
    apply.ftl
    status.ftl
    notices.ftl
    appeals.ftl

Document Upload

Applicants upload verification documents (pay stubs, ID, utility bills) via the portal. Files are stored in S3-compatible storage (Garage in devstack) via canopy-store.

Restrictions:

  • File types: PDF, JPEG, PNG, TIFF (reject all others at the HTTP layer)

  • Maximum file size: 10 MB per file, 50 MB per application

  • Virus scanning: ClamAV integration via server function before S3 storage (deferred to production hardening; NoopScanner for devstack)

  • PII handling: uploaded documents are stored in the program-specific S3 bucket per ADR-004 (SNAP documents in the SNAP bucket, not a shared bucket)

  • Metadata: original filename, MIME type, upload timestamp, file hash (SHA-256) stored in canopy-applications

  • Retention: per jurisdiction retention policy (Georgia: 3 years after case closure)

Accessibility (Section 508 / WCAG 2.1 AA)

The portal meets WCAG 2.1 AA. This is a legal requirement under Section 508, not a best-effort goal.

Mandatory patterns (proven in IMTN):

  • Semantic HTML landmarks: <nav>, <main>, <footer> on every page

  • Skip-to-content link (visible on Tab focus)

  • All form inputs have associated <label> elements

  • Validation errors announced via role="alert" + aria-live="polite"

  • Required fields marked with asterisk + required attribute

  • Modal dialogs: role="dialog", aria-modal="true", Escape key closes

  • Toast notifications: role="alert", aria-live="assertive"

  • Keyboard navigation: all interactive elements focusable, logical tab order

  • Focus management: focus moves to first error on validation failure

  • Color contrast: Orchard palette verified via axe-core (light and dark themes)

  • prefers-reduced-motion: animations disabled when user requests it

  • prefers-color-scheme: system theme respected, manual toggle available

  • All DOM manipulation via typed web-sys bindings (no document::eval())

Testing:

  • axe-core integration in Playwright E2E tests — zero WCAG 2.1 AA violations on every page

  • Keyboard navigation tests for all interactive flows

  • Screen reader testing targets: NVDA (Windows), VoiceOver (macOS/iOS), TalkBack (Android)

  • Mobile viewport testing: 320px, 375px, 768px, 1024px

CSP (Content Security Policy):

Per-response random nonce for all <script> tags. No 'unsafe-inline' or 'unsafe-eval' for scripts. 'wasm-unsafe-eval' required for WASM instantiation (standard necessity). Style: 'unsafe-inline' permitted (Dioxus may inject inline styles; mitigated by nonce enforcement on scripts).

NOTE

Superseded by Amendment 2 — Strict CSP (2026-05-29). The style-source allowance described in this paragraph is no longer in effect. 'unsafe-inline' for styles is not permitted; 'wasm-unsafe-eval' is the only unsafe directive allowed in the policy.

Multi-Program Application Flow

The portal implements the ACA §1413 single-streamlined application. Applicants fill out one form. The system determines eligibility for all applicable programs (SNAP, TANF, Medicaid/CHIP, CAPS, WIC) from a single submission.

Form sections:

  1. Household composition (who lives with you, relationships, ages)

  2. Income (earned, unearned, self-employment — per household member)

  3. Assets (bank accounts, vehicles — if applicable per program)

  4. Expenses (shelter, dependent care, medical — for SNAP deductions)

  5. Citizenship/immigration status (per household member)

  6. Program-specific questions (ABAWD work status, disability, pregnancy)

  7. Document upload (optional at submission; verification requests follow determination)

  8. Rights and responsibilities acknowledgment

  9. Electronic signature

Each section is a Dioxus component with client-side validation and server-side validation on submit. Draft data is saved to the server on section completion (not just localStorage) so applicants can resume from any device.

Platform Targets

Phase 1 (UAT): Web browser (SSR + WASM). Responsive design covers mobile, tablet, and desktop.

Phase 2 (post-UAT): DHS field office kiosk. Same Dioxus codebase compiled to a native desktop target (Dioxus desktop renderer). Kiosk mode: no URL bar, no navigation, session auto-expires. Offline-capable: Service Worker caches the application shell; form data syncs when connectivity resumes.

Phase 3 (future): Mobile app. Same Dioxus codebase compiled to native mobile targets via Dioxus mobile renderer. Document upload uses device camera directly. Push notifications for determination results.

The single-codebase-multiple-targets story is the primary reason for choosing Dioxus over Askama. Askama renders HTML on the server — it cannot compile to desktop or mobile.

Relationship to canopy-web

canopy-web (worker portal) remains Askama + htmx + Alpine.js. canopy-portal (applicant portal) is Dioxus fullstack. They are separate binaries with separate sessions, separate Orchard theme variants, and separate deployment profiles.

Shared infrastructure:

  • Both use canopy-api for bootstrap, middleware, health checks, and metrics

  • Both use canopy-auth for Keycloak JWKS validation (canopy-portal uses it for worker-impersonation endpoints only, not for applicant auth)

  • Both use the Orchard design system (Tailwind v4 + Orchard CSS tokens)

  • Both deploy as Docker containers in the same Compose stack

  • Both are optional per ADR-005 (canopy-portal is not required for SNAP-only UAT)

The two portals do not share components, templates, or rendering code. This is intentional — their interaction patterns are different enough that forcing shared components would compromise both.

Consequences

  • canopy-portal becomes a Dioxus fullstack binary instead of an Askama BFF. The current session-only stub (services/canopy-portal/) is replaced entirely.

  • The project gains a second frontend paradigm (Dioxus alongside Askama). Developers working on canopy-portal need Dioxus knowledge; developers working on canopy-web do not.

  • Dioxus 0.7+ is a pre-1.0 dependency. API changes between Dioxus versions require migration effort. This risk is mitigated by IMTN’s successful tracking of Dioxus updates.

  • dioxus and web-sys are added to workspace dependencies. WASM compilation requires wasm32-unknown-unknown target installed.

  • The applicant portal’s E2E test suite requires axe-core + Playwright, matching IMTN’s infrastructure.

  • Fluent .ftl files become a first-class artifact in the repository under locales/.

  • Document upload requires ClamAV integration for production (NoopScanner for devstack).

  • The reference-number authentication model means applicant data is not protected by Keycloak — the portal must enforce its own rate limiting, brute force protection, and session security.

Alternatives Considered

Askama + htmx (same as canopy-web)

Proven pattern, same as the worker portal. Rejected because:

  • Cannot compile to desktop or mobile targets — locks the portal to web-only permanently

  • Multi-step form UX with save-and-resume, client-side validation, and draft persistence is awkward in server-rendered HTML with htmx. Possible, but the code would fight the paradigm.

  • No path to offline capability (Service Worker + app shell requires a client-side application)

  • Two Askama portals would share more code in theory but serve different enough audiences that shared components would be forced abstractions

React / Next.js (rejected)

Would provide excellent component ecosystem and SSR. Rejected because:

  • Introduces JavaScript/TypeScript into an all-Rust codebase

  • Cannot share types or validation logic with backend crates

  • Requires a separate build toolchain (Node.js, npm/yarn)

  • Contradicts the project’s Rust-first philosophy

  • No path to native desktop/mobile from the same codebase

Leptos (considered, not selected)

Rust WASM framework with fine-grained reactivity and SSR. Not selected because:

  • IMTN has already proven Dioxus in the Georgia government context with the same design system

  • Reusing IMTN’s component patterns, accessibility infrastructure, and E2E test suite provides significant acceleration

  • Leptos and Dioxus are comparable in capability; the deciding factor is organizational experience, not technical merit

Progressive Web App only (deferred enhancement)

Add Service Worker and manifest.json to the Dioxus web app for offline support and mobile installability. This is planned as a Phase 2 enhancement, not an alternative architecture. The Dioxus fullstack approach accommodates PWA features without architectural changes.

Amendments

ADR decisions are immutable historical records; later decisions supersede rather than rewrite them. The sections below amend the original Decision above. Each amendment is additive — the superseded text remains in place with a forward NOTE pointing here.

Amendment 1 — Credential format (2026-05-29)

Supersedes: the "Session Model" reference-number format and the "Authentication" reference-number-plus-date-of-birth pair.

Decision. The returning-applicant authentication pair is an Application ID plus a Passcode:

  • Application ID — format HH-[a-f0-9]{8} (an HH- prefix denoting the household, followed by 32 bits of lowercase hex). This is the public identifier the applicant references when they return; it is not a secret.

  • Passcode — format word-word-word-NN: three words drawn from a curated, profanity-screened 4–7-character wordlist (English and Spanish bundles) plus a two-digit number (0099). This is the secret factor, generated by the system and delivered once.

    NOTE
    The passcode format is superseded by Amendment 3 — Passcode format: all-digit (now NNNN-NNNN-NNNN); see the Amendments section.

Date of birth is never used as an authentication factor.

Rationale. The applicant-portal threat model is intimate-threat-dominant: the most likely adversary is a household member, former partner, or caregiver who already knows the applicant’s date of birth, address, and case details. A date of birth is therefore not a secret and cannot serve as an authentication factor. A system-generated random passcode is unknown to such an adversary and is independently revocable. The HH--prefixed Application ID and the three-word-plus-digits passcode also read aloud and transcribe more reliably than an opaque alphanumeric string — important for applicants assisted over the phone or at a kiosk. This format is the contract locked in the canopy-portal design handoff (design reference).

+ NOTE: The passcode format is superseded by Amendment 3 — Passcode format: all-digit (now NNNN-NNNN-NNNN); see the Amendments section.

Consequences. The brute-force / rate-limiting protections in the "Authentication" section apply to the Passcode, not to a date of birth. The lost-credential recovery flow (a later plan deliverable) issues a new passcode rather than disclosing the existing one, and is gated by a delay + side-channel notification + kill-switch per the handoff’s intimate-threat mitigations.

Amendment 2 — Strict Content Security Policy (2026-05-29)

Supersedes: the "Accessibility › CSP" allowance of 'unsafe-inline' for styles.

Decision. 'wasm-unsafe-eval' is the only unsafe directive permitted in the portal’s Content Security Policy. Specifically:

  • Scripts — per-response random nonce on every <script>; no 'unsafe-inline', no 'unsafe-eval'.

  • WASM'wasm-unsafe-eval' only (required to instantiate the WASM module; unavoidable for a Dioxus client).

  • Stylesno 'unsafe-inline'. Styling is class-only against a static stylesheet shipped at services/canopy-portal/assets/canopy-portal.css; components emit no inline style= attributes.

Rationale. Inline-style allowances widen the XSS surface and are inconsistent with the strict, Kerckhoffs-aligned security baseline the portal is held to (see the project security baseline). The original allowance assumed Dioxus must inject inline styles; that assumption is the subject of a build-time spike (see Consequences).

Consequences. Whether Dioxus 0.7 can render with zero inline-style emission is verified by a CSP + routing spike before any UI components are built — a hard gate: no component emission proceeds until the spike proves zero-inline-style output or an explicit hash-based style-src fallback (enumerated style hashes, still never 'unsafe-inline') is ratified. This spike and its outcome are tracked at #630.

Amendment 3 — Passcode format: all-digit (2026-05-30)

Supersedes the passcode half of Amendment 1 — Credential format. The passcode changes from word-word-word-NN (three wordlist words plus two digits) to NNNN-NNNN-NNNN — twelve digits in three dash-separated groups of four, drawn as a single uniform CSPRNG value over 0999999999999 (leading zeros valid; the dashes are cosmetic). Entropy is ~40 bits (10^12 ≈ 39.9 bits), matching the prior target.

Rationale: the constituency includes functionally illiterate applicants, and the passcode is consumed by read-aloud (kiosk/assisted), IVR, and screen-reader/TTS channels. An all-digit passcode (a) needs only number recognition — more universal than word literacy and language-independent (no English/Spanish wordlists to curate or review); (b) carries zero stigma risk — wordlist passcodes can assign distressing words (e.g. pauper, cancer, corpse) to vulnerable applicants; (c) is keypad-enterable via DTMF on the IVR, eliminating any speech-recognition requirement; and (d) has no homophones, so it transcribes unambiguously through TTS and over the phone. The Application ID remains HH-[a-f0-9]{8} — its hex letters now also serve to visually distinguish the ID from the all-digit passcode, reducing field-swap errors. Generation MUST be a uniform CSPRNG draw over the full range (no leading-zero avoidance, no weak RNG), or entropy is silently lost. The profanity-screened wordlist (and its English/Spanish bundles) is no longer needed and is removed from scope.

This supersedes Amendment 1’s passcode format only; the HH-… Application ID, the rule that date of birth is never an authentication factor, and the intimate-threat rationale are unchanged.

Amendment 4 — Upload scanning is asynchronous quarantine (2026-08-10, ADR-042 / #1006)

Two clauses above are superseded by ADR-042:

  1. "Virus scanning: ClamAV integration via server function before S3 storage (deferred to production hardening; NoopScanner for devstack)" — scanning is now ASYNCHRONOUS and after storage: every upload lands durable at scan_status='pending' and a fenced promotion worker settles the verdict; serving gates on viewability, never on scan timing. The devstack runs the real clamd sidecar; NoopScanner is no longer the devstack default and requires the accountable override outside development.

  2. "Document upload requires ClamAV integration for production (NoopScanner for devstack)" — same supersession; the production requirement is now enforced by the fail-closed boot guard (CANOPY_APPLICATIONS__ALLOW_INSECURE_SCANNER), not convention.

Edit this page · default