Applicant Portal Design Reference

On this page

Overview

This page is the durable reference translation of the constituent-facing portal design contract (design/canopy-portal/). The applicant portal (services/canopy-portal) is a Dioxus 0.7 fullstack surface (SSR + WASM hydration) for benefit recipients — distinct from the caseworker-facing worker portal documented in Worker Portal Mockups.

The handoff is the source of design intent; the Rust types, copy strings, and locked auth decisions below are derived from it. Many Rust code comments and other docs cite specific subsection anchors ("applicant-portal design ref §3.7", "§4.5", "§6.5"), so this page preserves the exact X.Y numbering in its section titles.

NOTE

This page documents design intent, not what ships today. The applicant portal’s current implementation status (live routes, ADR-026 session model) lives in the service catalog and in ADR-026. Where this reference describes a screen or schema that is not yet built, treat it as the design contract the implementation works toward.

Relationship to the ADRs

ADR What it governs relative to this page

ADR-008

Applicant portal architecture (Dioxus fullstack, containerized surface).

ADR-026

The privacy-first stance: opaque Redis-primary sessions, the recovery flow, confidential-case handling, and the intimate-threat model that §3.7–§3.12 below encode.

IMPORTANT

The credential format here supersedes ADR-008’s original sketch. ADR-008 contemplated a reference number plus a date-of-birth second factor. This design replaces that with an Application ID (HH- + 8 hex) paired with a word-word-word-NN passcode and uses no DOB as a steady-state login factor. DOB survives only as one entry gate into the recovery flow (§3.7), never as the primary login second factor. The locked auth model below (§3.1–§3.12) is authoritative over the ADR-008 prose where they conflict; ADR-026 ratifies this newer model.

1. The thesis

No accounts. No passwords. No usernames. No "forgot password" purgatory.

Recipients have hard enough problems. The portal is identified by a single artifact issued at application time:

  • Application IDHH- + 8 hex chars (e.g. HH-c8841a23)

  • Passcode — 3 dictionary words + 2 digits (e.g. maple-river-orchard-44)

The pair is generated server-side on application submit, displayed once on the Submitted screen with a giant "save this" affordance (copy / download / screenshot prompts), and never echoed in plaintext again. If they lose them, a 4-step self-serve recovery flow can mint a fresh view of the passcode (see §3.7) — and beyond that, a phone-call fallback exists.

This forces a few good consequences:

  • No password reuse risk. The credential pair is meaningful only to canopy.

  • No phishing surface. No "login" page that fraudsters can mimic.

  • No abandoned accounts. The portal IS the case; closing a case closes the way in.

  • No session theft via stolen password. Sessions are scoped to the device that authenticated and short-lived.

The tradeoff is that lost-passcode recovery is heavier than email/SMS resets. That’s intentional. The whole point is to avoid the auxiliary credential plumbing that fraudsters target — recovery email accounts, SIM-swap-able phone numbers, etc.

3. The auth model in detail

This is the load-bearing, locked-decisions section. The subsection numbering below is cited from code; do not renumber.

NOTE

§3.5 was moved to §3.12 ("Shared / kiosk devices") in the source handoff. The §3.5 anchor is intentionally a forwarding stub so that older citations of "§3.5" resolve to the shared/kiosk content now under §3.12.

3.1 Application ID format

HH-[a-f0-9]{8}
  • Prefix HH- for Household. Always uppercase.

  • 8 hex chars. Stable for the life of the case.

  • Survives case state transitions. A closed case retains its ID forever for audit.

  • One ID per household. Multiple programs (SNAP + Medicaid + WIC) share the same ID.

3.2 Passcode format

word-word-word-NN
  • 3 words drawn from a curated wordlist of ~2000 common English nouns and adjectives. 4-7 chars each.

  • 2-digit suffix. Disambiguates collisions and adds bits without making the passcode hostile to remember.

  • ~2000³ × 100 ≈ 8 × 10¹¹ ≈ ~40 bits of entropy. Combined with the cascade in §3.6 and the 8-hex-char ID, comfortably above the threshold for the threat model.

  • Spanish wordlist must exist for language: es-US jurisdictions. Same shape, different words.

  • Lowercase, hyphen-separated. Never displayed mixed-case.

3.3 Session

  • Successful lookup mints a device-bound session token, stored in indexedDB (encrypted at rest).

  • Default inactivity timeout: 30 min in the apply / recovery / renewal flows, 2 hr in the steady-state portal, 15 min in shared kiosk mode (a launch flag — see §3.12).

  • Re-auth requires both ID and passcode again. No "remember me" toggle; the device-bound token is the only persistence.

  • Token rotation: on every authenticated request, mint a fresh token; previous token grace-window of 30s for in-flight requests.

3.4 Lost credentials

There are now two paths, in order of preference:

  1. Self-serve recovery (the prototype’s recover.jsx) — a 4-step identity challenge that ends in a 24-hour pending reveal with kill-switch notification. See §3.7 for the threat model. The flow itself always accepts input; the server short-circuits to ConfidentialBlocked after a successful App-ID lookup if the case is flagged confidential (§3.8). This is by design: Lookup cannot peek at confidentiality from no credentials without leaking case existence.

  2. Helpline / in-person fallback1-877-423-4746, Mon–Fri, 7am to 6pm. The only path that resolves for confidential cases. Reachable from inside the self-serve flow when challenges fail or the case is confidential, and prominently displayed on Lookup.

The self-serve path must:

  • Reject any input that doesn’t match. Never reveal whether the ID exists. Generic "We couldn’t find that case" error.

  • Run inside the rate-limit cascade (§3.6).

  • After challenges pass, enter a 24-hour pending state during which the passcode is NOT shown and existing sessions remain active.

  • Fire a side-channel notification (email + SMS if both exist) to the contact on file at time of original application, not the most recently changed contact (§3.10).

  • The notification includes a one-tap "this wasn’t me" kill-switch.

  • Never email or text the passcode itself. The passcode is shown only in the open session that initiated recovery, after the 24h window, behind a blur-reveal toggle, once.

3.5 (moved to §3.12)

See §3.12 "Shared / kiosk devices". This anchor is retained so older citations of "§3.5" resolve.

3.6 Rate-limiting — the cascade

Never primary-key on IP. Cellular carriers route huge fractions of their users through a small pool of shared public IPs (CGNAT). A clumsy "5 attempts / hour per IP" cuts off thousands of legitimate phone users when one attacker abuses recovery from the same carrier. The right shape:

  1. Per-device cookie (primary) — first signal we check. Set a long-lived, HttpOnly, SameSite=Lax cookie on first visit (e.g. cy-dev) containing a random 128-bit ID. Limit per-device: 5 attempts / hour, 10 / day, exponential cooldown beyond. Attackers rotate cookies, legit users don’t.

  2. Per-CaseID cap (always) — independent of device or IP, a specific case cannot accept more than 8 wrong attempts / day before that case enters cooldown. Defends against brute force targeting a known ID.

  3. Per-IP fallback (last resort, generous) — only when both the cookie is missing AND no per-CaseID counter applies. Set the limit high enough that a busy carrier NAT (~thousands of users) doesn’t trip it: 300 attempts / hour / IP. Below the device cap so a determined attacker hits the per-device limit long before the IP limit.

  4. Per-IP-subnet anomaly detection — separate, observability layer. Watch for "1,000 attempts in 5 minutes from a /24" patterns and flag for human review. Never auto-block based on this — the rate-limiter handles the auto-response; this just surfaces unusual patterns.

Same generic error for every rejection. "We couldn’t find that case." Don’t reveal which limit was hit, which counter is closest to triggering, or whether the ID exists. Even a 429 with Retry-After leaks structure — return 401 consistently and only use 429 once cooldown is in place for all paths from that source.

Recovery flow lockout is in addition to all this. Two wrong identity-verification answers inside the recovery flow → flow locks for that session and dumps to "call us" (see recover.jsx). This is per-flow, separate from rate-limit counters.

Cookie-deleting attackers. A determined attacker clearing cookies between attempts loops back to the per-IP fallback fast. That’s fine — the per-IP cap is high but exists. And the per-CaseID cap caps total brute-force progress against any specific case regardless of source.

CGNAT edge: known carrier subnets get a generous cap. AT&T, T-Mobile, and Verizon publish their CGNAT subnet ranges; busy NATs can have 10,000+ users on one IP. The default 300/hr/IP could trip on a heavily-used carrier IP under attack. Maintain a list of those subnets and raise the cap to 3,000/hr/IP on them, OR skip per-IP for those subnets and lean on per-CaseID as the only backstop. This is operational tuning, not policy — watch the dashboards.

One more layer for high-value paths. Recovery completion (passcode reveal) should require a CAPTCHA-equivalent challenge gate — but design it as a "we’re checking that you’re a person" friendly screen, not a wall of squiggly text. Cloudflare Turnstile or hCaptcha invisible mode is fine; do not use Google reCAPTCHA (deanonymization concern for a benefits population).

3.7 Recovery threat model

The challenges in recover.jsx are not a security boundary on their own — they’re the second wall after the rate-limiter. But the real threat is not the random stranger; the real threat is the person who knows.

Intimate threats are the dominant threat model. Benefits as control is a well-documented domestic-violence pattern: an abusive partner cancels EBT to coerce, a non-custodial parent intercepts a kid’s Medicaid information, a controlling adult child manages an elderly parent’s case without consent. These people know the recipient’s DOB, address (often better than the recipient), the kids' names, the SSN’s last 4, the worker’s name, and have physical access to mail and devices.

The recovery flow must assume this person exists and is patient.

Threat Mitigation

Stranger guesses someone’s passcode

Per-CaseID cap (§3.6) — 8 wrong attempts / day

Stranger knows ID + DOB (data breach)

Recovery requires multiple matched answers + 24h delay

Wide-net automated guessing

Per-device cookie cap + per-IP fallback + CAPTCHA on reveal

Intimate threat (partner, ex, family)

Confidential-case flag disables self-serve entirely (§3.8); 24h delay-with-kill-switch on every reveal; side-channel notification to the application-time contact, not the current contact; recovery flow exposes a safety exit at every step

Coercion during recovery (sitting next to the person)

The 24h delay means the abuser cannot use the credentials in the moment. The "this isn’t safe for me right now" exit appears in the header of every recovery/change/apply screen.

Caseworker (insider)

Out of scope here — handled by worker portal audit trail.

Identity-verification questions, redesigned:

  1. Application ID + DOB — required to enter self-serve recovery at all. There is no "I don’t have my ID" alternative. The App ID is the only piece of identifying information an intimate threat is unlikely to have memorized (random hex, not personally derived). Without it, recovery is phone-only.

  2. A recent letter ID ("Type the NTC- number from any letter we mailed in the last 6 months") — this requires mail interception, which is harder than knowing static info, especially after the relationship ends.

  3. Rough year of last decision (with "I’m not sure" as a valid option that proceeds — kept from v1, low entropy but useful redundancy).

The previous v1 address-challenge step is removed for the same reason as the name+DOB+SSN entry path: address is on every piece of mail an intimate threat has handled and often on a co-signed lease.

Why no SSN/name/DOB-only entry path. It seems "more accessible" but it’s the opposite. Anyone with reasonable knowledge of the recipient already has SSN’s last 4, DOB, and the name on the case. That path lets the abuser in. The Application ID gate is the only way self-serve can ever be safer than calling.

The questions are deliberately not SSN-as-primary anywhere. SSN is the most-leaked piece of identifying information; tying a system’s security to SSN recoverability is bad practice generally and especially bad here.

On 2 wrong answers in this flow, the user lands on the RecoverLocked screen and must call the helpline. The lockout is per-session (cookie); it doesn’t compound with the rate-limit cascade so an honest user typoing twice doesn’t get locked out of the whole system.

After all challenges pass:

  • Mint a pending recovery, not a successful one. Hold the passcode reveal for 24 hours.

  • Fire the side-channel notification immediately (§3.4) to the contact on file at the time of the original application, not to any recently-updated contact (a recently-changed contact is a possibly-controlled contact).

  • The notification includes a one-tap "this wasn’t me" link that cancels the pending recovery and locks the case (worker contact required to unlock).

  • Existing logged-in sessions are not invalidated. Instead, prior sessions receive a one-shot prompt: "an account recovery is pending — if it wasn’t you, tap KEEP." The KEEP wins.

  • After 24h with no cancellation, the passcode reveal screen becomes accessible (one-shot, blur-toggle) and existing sessions are then asked to re-authenticate.

This is more friction than v1’s "verify → reveal." That’s the design. The 24-hour window is the difference between "your abuser has your benefits right now" and "your abuser has 24 hours to surface to you, and then you catch them."

3.8 Confidential cases

A Case carries a confidentiality: Confidentiality flag:

enum Confidentiality {
    Standard,                 // default — self-serve recovery available
    Confidential,             // self-serve recovery disabled; phone/in-person only
    AddressConfidential,      // address never echoed; ACP routing applies (see §3.9)
    Both,                     // confidential AND address-confidential
}

Setting paths:

  • During apply, after the household step, a screen asks: "Is anyone in this application escaping violence, stalking, or someone who shouldn’t have access to your information?" If yes → a follow-up explains the implications and offers Confidential mode, AddressConfidential (if the state has an ACP), or both.

  • By the recipient any time from a future Safety settings screen (v1.1).

  • By worker request through the worker portal when the customer asks by phone or signals risk on the call.

When Confidentiality != Standard:

  • The Lookup screen still offers "Recover my access" — hiding it would leak case existence to anyone who knows an App ID. The flow accepts input and the server routes to ConfidentialBlocked after the App-ID lookup matches a confidential case.

  • All identifying responses go through human verification (worker phone or in-person office).

  • The portal still works for the legitimate user with the credentials, and the worker portal flags any attempt to recover via self-serve.

  • Worker-side note: caseworkers cannot disable Confidential status without supervisor approval and a logged reason. Recipient can disable from settings (with confirmation).

3.9 Address Confidentiality Programs

Most states have an ACP for survivors of domestic violence, stalking, or sexual assault. The recipient uses a state-provided substitute address; the state forwards mail. Canopy must respect this:

  • ACP-flagged cases store the substitute address as the visible address

  • The real address is held in a separate protected_address table accessible only by privileged worker roles + audit-logged on access

  • The Address change-of-info flow for an ACP case routes to the state ACP office, not directly to canopy

  • The portal never displays the substitute address in challenge questions or any visible UI surface that could be screenshotted

  • The Recap removes any address-derived information (closest-store-city) for ACP cases

Georgia’s ACP is the Address Confidentiality Program operated by the Criminal Justice Coordinating Council; the actual substitute-address format and forwarding API are state-specific and out of scope for this design but required for the implementation.

3.10 Contact-info change protocol

Scope. This section governs ChangeRequest of kind Contact against an existing case — i.e. changes the recipient makes after their case is created. It does not apply to the initial contact entries on the Apply form (there’s no "old contact" to dual-confirm against).

Changing a phone or email must require dual confirmation, never silent:

  1. The new contact receives a verification code; the user enters it.

  2. Simultaneously the old contact (if any) receives a "your contact info is being changed to X — if not you, tap to block this for 24 hours."

  3. The change is applied after a 24-hour delay with no block.

  4. If old contact blocks: change reverts, case is flagged in worker portal for review.

  5. If old contact is no longer reachable (e.g. lost phone) and the user can’t verify ownership of it, the change MUST route through the helpline.

A recently-changed contact (within 30 days) is never the primary recipient of a recovery-attempt notification — the old contact, if any, is also notified. This breaks the "attacker changes phone, then 'recovers' the account" attack.

3.11 Safety exits

The header of every flow that touches credentials, contact info, or high-stakes case state (recover, change, apply, messages compose view) includes a discrete "Get help · this isn’t safe for me right now" link.

Tapping it routes to the Safety screen (designed for v1.1, scaffolded behind the link today), which:

  • Lists the National DV Hotline (1-800-799-7233) and a county-routed local hotline

  • Lists the county canopy helpline for the case

  • Offers an in-person office locator

  • Offers to mark the case Confidential immediately

  • Does not echo any case data on screen (no name, no address, no case ID — so screen-sharing scenarios stay safe)

  • Has a giant "Hide this screen now" button that immediately goes to a generic weather page (the common "out-the-back-door" pattern)

3.12 Shared / kiosk devices

Library, family computer, county-office kiosk. The reality.

  • Sign out lives in the side rail’s persona footer (desktop) / avatar-tap menu (mobile). It clears indexedDB + revokes the server token. (Designed but not yet rendered in the prototype — implementation gap.)

  • All sessions auto-expire on tab close + the inactivity timeout for the active flow.

  • The Welcome screen never shows persona detail.

  • The Save-This screen (post-submit) and the Reveal screen (post-recovery) never echo the passcode after the user navigates away — both are one-shot views.

  • Kiosk mode (?kiosk=1 or build flag) shortens timeouts, hides the device-cookie nudge, and skips storing language/theme prefs to localStorage.

4. Information architecture

Tabs in the portal (mobile bottom, desktop side rail):

Tab Screen Renders

Home

HOME

Per-caseState hero + programs + timeline + Discover/Recap tiles + quick links

Letters

LETTERS

Inbox, filterable; opens LETTER detail

Files

DOCUMENTS

Upload zone + history; the action surface for verification

Help

HELP

Phone numbers, worker contact, FAQ

Deep / contextual screens — reachable but not tabs:

Screen Reached from

MESSAGES

Home quick link · Help → "Send a message"

CARD

Home → Food Benefits program card → "See my card"

DISCOVER

Home → DiscoverTeaser tile

RECAP

Home → RecapTeaser (seasonal — Dec–Feb)

CHANGE

Home quick link "Report a change"

RENEWAL

Home hero (only when caseState === renewal)

RECOVER

Lookup screen → "Recover my access"

4.1 Why exactly 4 tabs

Tested mental model — Home / Letters / Files / Help maps directly to "what’s going on", "what they sent me", "what I send them", "I need a person." Adding a 5th tab (Messages, Profile, Apply for more) hurts more than it helps. Profile is one tap into Home or accessed from the persona footer (desktop) / avatar (mobile). Apply-for-more belongs in Discover, not the nav.

Messages was the strongest pull for a 5th tab. We kept it out because (a) recipients message the worker rarely (estimated <1 message/case/month based on phone-helpline data), (b) the badge on Letters already creates one prominent unread surface — two competing red dots is worse than one, (c) Home’s quick-link tile + Help → "Send a message" hits the most common discovery paths.

Inboxes don’t get long enough. Active cases generate ~6 letters/year. Search becomes relevant at year 3+ and is in the v1.1 list.

4.3 Why Help has a real screen, not a modal

For the audience. A modal closes when you tap outside; a screen is a place you can dwell in while you dial the number. Don’t mistake low-tech for careless design.

4.4 Recap visibility (seasonal)

The RecapTeaser on Home is gated behind a should_show_recap() flag the implementation owns. Suggested logic:

  • Show in December 15 → February 15 of each year, against the prior year’s data

  • Show in anniversary month (the month of first approval) as "Your first year on canopy" — same template, different intro

  • Always reachable via direct URL (?screen=recap) regardless of season — useful for sharing

  • Opt-out lives in account settings (v1.1) — some recipients won’t want their year quantified, and that’s their call

4.5 Eligibility guidance — encouragement, not gating

The portal ships a copy of the active jurisdiction’s eligibility rules and runs a non-binding estimate during the apply flow. The server runs the binding determination after submit. The client-side estimate exists for one purpose: make recipients more likely to fill out their applications completely. It is NEVER allowed to block, hide, or otherwise gate submit.

The dark version of this idea is real and dangerous. Telling a recipient "you don’t qualify" — even softly — measurably drops submission rates, including for cases that would qualify if a worker had seen the whole picture (medical deductions, dependent care, categorical eligibility, expedited circumstances). Under-enrollment is already the dominant problem in US safety-net programs. The portal must push toward submission, not away.

The good version: estimate + actionable nudges + pivots.

Three tiers the estimate can take, and what each one says:

Tier Headline What follows

likely

"Households like yours usually qualify."

Quiet "Show why" expander — calm, confident. Nothing else.

borderline

"You’re close to the line, but the next questions usually decide it."

Flip hints: childcare costs, medical $35+/mo, rent/utilities. Each one is a real next-step question that the form is about to ask.

unlikely

"Food benefits may not be the right fit at this income — but a couple of other programs almost certainly are."

Flip hints (smaller list — elderly/disabled override) PLUS pivots into Discover programs (Medicaid, NSLP, Lifeline) that almost certainly qualify.

The borderline tier is the design’s center of gravity. "You’re $40 over the cap, but the form is about to ask about childcare and medical — those flip a lot of borderline cases" is the script the portal whispers to a recipient who would otherwise drop off thinking they don’t qualify. This is the game-changer. A jurisdiction running this in production should expect a measurable lift in completed applications among households that used to abandon at the income step.

Where the estimate appears:

  1. End of household step (#2)EligibilityPeek (the smaller variant). Sets expectations only: "Most 4-person Georgia households qualify if income is under $3,380. We’ll ask income next." No verdict yet — there’s not enough data.

  2. End of income step (#4)EligibilityCheck (the full variant). Real estimate with one of the three tiers, expandable Why, flip hints, pivots if applicable.

  3. Review step (#5) — a quiet one-line summary. "Estimated · likely qualifies for SNAP and Medicaid · a worker reviews next."

Hard constraints on the rules engine and the UI:

  • The engine must handle categorical eligibility correctly. If anyone in the household has active TANF, SSI, or qualifying Medicaid, SNAP’s gross-income test is waived entirely. Skipping this would send false borderline signals to families who automatically qualify.

  • Elderly (60+) and disabled households have a different test (net income only, with extra deductions). The engine must apply the right test based on household composition.

  • Expedited SNAP (7-day fast track for very low income + low liquid assets) must surface a separate "You may qualify for expedited benefits — keep going, you’ll see them in your portal in days, not weeks" message.

  • Never call client-side output "determination" anywhere — even in variable names. Use eligibility_estimate or guidance. User-facing copy uses "likely qualifies" / "close to the line" / "may not be a fit" — never "qualified," "approved," or "denied."

  • Always-submittable. No code path can lock the submit button on a negative estimate. Submit is always one tap.

  • Show the rule that made the decision in plain words. The expandable Why must show actual numbers ("Income $3,420 · cap $3,380 · $40 over"), not jargon. Honest and lets people see a fat-fingered entry.

Where it lives in the binary. Rules per program per jurisdiction ship in a small TOML file under rulesets/{jurisdiction}/eligibility/. A SNAP ruleset is ~20–30 KB before compression, ~6–8 KB after. The active jurisdiction is the only one shipped — the binary doesn’t carry all 50 states. Annual COLA updates (October) ride the normal WASM update cadence.

Implications for the worker portal. The worker side gets the same estimate alongside the case. Easy cases route to a fast-approval queue; borderline cases route to deeper review. Better triage without burdening the recipient or expanding the worker UI.

WARNING

The client-side estimate is guidance only and never binding. The authoritative determination is computed server-side by the program services after submit. Client copy must never use "denied," "ineligible," "qualified," or "approved" — see hard rule §12.20.

4.6 Form validation + language switcher (built specs)

Both of these were prototype v1.1 gaps and are now built — port the behavior, don’t redesign it.

Inline field validation — reference: ValidatedField + errInputStyle in entry-apply.jsx.

  • Error visual: input border switches to error token; below the field, a row of bell icon (12px) + the message in the error token. Never color-alone (§10) — the icon + the literal sentence carry meaning for color-blind and screen-reader users.

  • Required marker: the muted word "· required" trailing the label. No asterisk — it fails the 6th-grade rule (§5); a recipient shouldn’t need to know the asterisk convention. Optional fields stay silent or say "Optional" in the hint.

  • Fire timing: on blur, once touched — never mid-keystroke. Track a touched map; a field’s error is suppressed until its first blur. On submit, flip all fields to touched so unblurred errors surface. After first blur, re-validate on every change so the error clears live when the value becomes valid.

  • Message voice: plain and specific — "Use MM/DD/YYYY — like 07/22/1991", not "Invalid format."

Language switcher — reference: LangPill, LangSwitch, useLang() in shell.jsx.

  • Mobile top bar: <LangPill /> — globe + current short code ("EN"), placed left of the theme toggle. Tap cycles EN↔ES.

  • Desktop side rail: <LangSwitch full /> — full-width segmented EN/ES in the footer cluster, directly above the theme toggle (language + theme group as "how I read this").

  • Globe, never flags — flags misrepresent languages (Spanish ≠ Mexico). Short code on mobile, endonym ("Español", not "Spanish") on desktop — name a language in its own words.

  • Persistence: useLang() writes cy-lang to localStorage; kiosk + reload safe. Production wires set() to the i18n catalog swap; the prototype swaps the visible label only. The es bundle already loads (§5 / Phase 8).

5. Voice and copy rules

These are tighter than the worker portal’s. The worker portal can use domain language; the customer portal must not.

Worker term Customer term

SNAP

Food benefits (the formal name appears once on the EBT card and on letters; otherwise "food benefits")

Medicaid

Health coverage

Notice

Letter

Recertification

Renewal

Income

Money coming in

Earned income

Money from work

Self-reported

What you told us

IEVS / discrepancy

A pay stub from the last 30 days

Pending verification

We need a pay stub

Determination

Decision

Authorized

Approved

Adverse action

(Spell it out — "your benefits will stop on…")

Fair hearing

Appeal

Head of household

(Don’t use; the form asks for "your name")

Eligibility worker

Your worker · Marcus

Case number / case ID

Application ID

EBT card

EBT card (this is what recipients call it; stays)

Other rules:

  • 6th-grade reading level target. Hemingway editor flags Grade 7 most days.

  • Sentences under 18 words. Most under 12.

  • Specific numbers and dates, not vague ranges. "$487 a month, on the 28th." Not "your monthly benefit."

  • Active voice for what we do. "We mailed you a letter." Not "A letter has been issued."

  • Plain past tense for finished things, future tense for upcoming things. Not present perfect.

  • Show emotion, never gush. "You’re approved." not "Hooray! 🎉 You did it!"

  • Mistakes are no big deal. "Wrong by a little? Just come back and update us." removes the punishment-anxiety that keeps people away from these systems.

  • Recap is the one place we lean warm. "Twelve months. Not one missed deposit." is editorial in a way the rest of the app isn’t. That’s the contract — the recap earns it because it’s rare.

6. Schemas implied by the design

The eventual Rust types. None of these exist yet; derive from these templates.

IMPORTANT

The Rust code blocks below are the schema contract. Preserve field names, ordering, and the inline comments faithfully when implementing — other docs and code comments cite these structs by name (e.g. "§6.5 `Letter`").

6.1 Case

struct Case {
    id: CaseId,                    // "HH-c8841a23"
    state: CaseState,              // enum below
    confidentiality: Confidentiality,  // see §3.8
    jurisdiction: String,          // "georgia"
    county: String,                // "Fulton"
    created_at: DateTime<Utc>,
    closed_at: Option<DateTime<Utc>>,
    closed_reason: Option<String>, // shown on the Closed Home hero
    applicant_id: ApplicantId,     // head of household
    household: Vec<HouseholdMember>,
    programs: Vec<ProgramEnrollment>,
    address: Address,              // substitute address only when confidentiality.address_protected
    protected_address: Option<Address>, // real address; privileged worker roles only
    contact: Contact,
    contact_history: Vec<ContactHistoryEntry>, // for §3.10 dual-confirmation + side-channel routing
    language: Lang,                // en-US | es-US ...
}

enum CaseState {
    Pending,            // application submitted, under review
    InterviewScheduled, // phone interview on the calendar
    Approved,           // < 14 days since first determination
    Active,             // > 14 days since approval, < 60 days from renewal due
    Renewal,            // inside renewal window
    Closed,
}

enum Confidentiality {
    Standard,             // default — self-serve recovery available
    Confidential,         // self-serve recovery disabled; phone/in-person only
    AddressConfidential,  // address never echoed; ACP routing applies (see §3.9)
    Both,                 // confidential AND address-confidential
}

6.2 ProgramEnrollment

struct ProgramEnrollment {
    program: Program,              // Snap | Medicaid | Tanf | Wic | Caps
    status: EnrollmentStatus,      // Active | Pending | Denied | Closed
    amount: Option<Money>,         // monthly benefit
    covers: Vec<HouseholdMemberId>,
    cert_start: Date,
    cert_end: Date,
    next_action: Option<NextAction>, // shown in Home timeline
}

6.3 Application

struct Application {
    id: CaseId,
    passcode_hash: PasscodeHash,   // argon2id; never persisted in plaintext
    submitted_at: DateTime<Utc>,
    submitted_by: Actor,
    answers: ApplicationAnswers,
    documents: Vec<DocumentId>,
    state: ApplicationState,       // InProgress | Submitted | UnderReview | Decided
}

Save-and-resume before submit. Decision settled on Option A: issue a temporary DraftCode on first save — DRAFT- + 8 hex chars — that doesn’t grant access to anything else. The draft is stored server-side and accessible only with the DraftCode. On submit, the Draft becomes the Case and a fresh CaseId + passcode are minted. Lifetime: 60 days, then the draft is wiped.

Entry point. The DraftCode is set in a long-lived HttpOnly cookie on the first save. When that cookie is present, the Welcome screen surfaces a third tile: "Continue where you left off · your draft saves automatically." The tile only appears when the cookie matches a still-live draft (server verifies before render). No cookie or expired draft → no tile, no leak. If a recipient is on a fresh device, recovery is phone-only — a worker can locate the draft from the recipient’s name + DOB and read them the DraftCode.

6.4 Document

struct Document {
    id: DocumentId,
    case_id: CaseId,
    uploader: Actor,
    uploaded_at: DateTime<Utc>,
    kind: DocumentKind,            // PayStub | Lease | PhotoId | SsnCard | Other
    filename: String,
    mime: String,
    size: u64,
    status: DocumentStatus,        // Pending | Accepted | Rejected
    request_id: Option<RequestId>, // ties back to the worker request that asked for it
}

6.5 Letter (formerly Notice)

struct Letter {
    id: LetterId,                  // "NTC-2026-0921" — the legal ID format is unchanged
    case_id: CaseId,
    sent_at: DateTime<Utc>,
    category: LetterCategory,      // ActionNeeded | Reminder | Decision | Confirmation
    subject: String,               // 6-8 words, the gist
    plain_summary: String,         // 1-2 sentences, 6th grade
    body: Vec<String>,             // paragraphs of the formal letter
    program: Program,
    signed: bool,                  // JWS-signed copy exists on disk
    read_at: Option<DateTime<Utc>>,
}

The subject + plain_summary is the new payload. The formal body is identical to the mailed PDF. Both must be authored by the worker / template; do not synthesize the plain summary at render time.

6.6 RenewalAnswer

struct RenewalAnswer {
    case_id: CaseId,
    submitted_at: DateTime<Utc>,
    household_changed: bool,
    household_change_note: Option<String>,
    income_changed: bool,
    income_change_note: Option<String>,
    address_changed: bool,
    new_address: Option<Address>,
    paystub_document_id: Option<DocumentId>,
}

If all _changed flags are false, renewal is "pure confirmation" — a worker can fast-approve without re-evaluating. That’s the workflow incentive design.

6.7 ChangeRequest

struct ChangeRequest {
    id: ChangeId,
    case_id: CaseId,
    submitted_at: DateTime<Utc>,
    kind: ChangeKind,              // Income | Household | Address | Contact | Other
    change_type: Option<String>,   // "new_job", "moved_in", "moved", etc. — kind-specific
    affected_person: Option<HouseholdMemberId>,
    effective_date: Option<Date>,
    new_value: ChangeValue,        // tagged union by kind — see below
    notes: Option<String>,
    documents: Vec<DocumentId>,
    status: ChangeStatus,          // Submitted | UnderReview | Applied | Rejected
}

enum ChangeValue {
    Income { monthly_amount: Money, source_description: String },
    Household { name: String, dob: Option<Date>, relationship: String },
    Address(Address),
    Contact { phone: Option<String>, email: Option<String> },
    Other,
}

The 10-day reporting requirement is the legal context — design copy reflects it. The worker side queues these by jurisdiction → routes to the right caseworker → applies on approval.

6.8 Message and MessageThread

struct MessageThread {
    case_id: CaseId,
    participants: Vec<Actor>,      // typically [customer, primary_worker]; supervisor can be added
    created_at: DateTime<Utc>,
    last_message_at: DateTime<Utc>,
}

struct Message {
    id: MessageId,
    thread_id: CaseId,             // 1:1 with case in v1
    from: Actor,
    sent_at: DateTime<Utc>,
    text: String,                  // max 2000 chars, validated for SSN/banking patterns
    attachments: Vec<DocumentId>,
    read_by: Vec<(Actor, DateTime<Utc>)>,
}

struct SystemMessage {
    thread_id: CaseId,
    at: DateTime<Utc>,
    kind: SystemMessageKind,       // PayStubDue | DepositLanded | InterviewScheduled | ...
    payload: serde_json::Value,
}

The customer thread is scoped to the assigned worker for v1. Supervisor shadowing (worker portal handoff §2.4) lets a supervisor see all threads without joining them. A worker reassignment (case routes to a new county on move) carries the thread.

SSN/banking guard. The text field is validated client-side and again server-side against patterns matching SSN (\d{3}-\d{2}-\d{4} and friends), bank routing/account number patterns. If a match is detected, the send is gated by a "Are you sure?" dialog explaining why this is risky. The prototype’s trust footer ("Don’t send Social Security numbers or banking info") is the user-facing copy.

6.9 DocumentRequest

struct DocumentRequest {
    id: RequestId,
    case_id: CaseId,
    asked_at: DateTime<Utc>,
    due_at: Date,
    document_kind: DocumentKind,
    instructions: String,           // shown in the Home banner + Documents callout
    resolved_by: Option<DocumentId>,
}

Home queries the open DocumentRequest`s and renders one banner per. Today the prototype shows at most one in the `active Home hero; the design accommodates two stacked banners (rare but real — e.g. pay stub + ID re-verify).

6.10 EligibilityHint (Discover)

struct EligibilityHint {
    case_id: CaseId,
    program: ExternalProgram,      // Lifeline | NSLP | LIHEAP | VITA | WIC | ...
    eligibility: HintEligibility,  // Automatic | Likely | Maybe
    affected_members: Vec<HouseholdMemberId>, // e.g. "Aiden qualifies for school lunch"
    plain_explanation: String,
    detail_fields: BTreeMap<String, String>, // populated per program (providers count, etc.)
    cta_url: Option<Url>,           // where the user goes to actually apply
    interested: bool,               // user's one-tap signal; surfaces in worker view
    last_computed: DateTime<Utc>,
}

Eligibility is computed, not hand-curated per case. The categorical rule ("if SNAP is active, Lifeline = Automatic") is encoded in a discover_rules.toml per jurisdiction:

[hints.lifeline]
program = "Lifeline"
trigger = "snap.active OR medicaid.active OR ssi.active"
eligibility = "Automatic"
plain_explanation = "Because you have SNAP, you qualify automatically."
cta_url = "https://lifelinesupport.org/"

The runtime evaluates triggers against the Case and emits `EligibilityHint`s for the Discover screen.

6.11 RecapData and the recap pipeline

struct RecapData {
    case_id: CaseId,
    year: u16,
    generated_at: DateTime<Utc>,
    benefits: BenefitsRollup,
    transactions: TransactionRollup,
    household: HouseholdRollup,
    notes: Option<String>,         // optional editorial line (jurisdiction-set)
}

struct BenefitsRollup {
    total_received: Money,
    months_covered: u8,
    per_person_average: Money,
    biggest_month: (Month, Money),
}

struct TransactionRollup {
    trip_count: u32,
    average_trip: Money,
    top_store: Option<(String, u32, String)>, // (name, visit_count, location_hint)
    by_month: Vec<MonthlySpend>,
    meals_estimate: u32,           // USDA-derived
}

struct HouseholdRollup {
    members_continuous: u8,
    members_changed_count: u8,
}

Where the data comes from. The benefits + household side is canopy’s own records. The transactions side comes from the state’s EBT processor (FIS, Conduent, etc. depending on state contract) — typically a daily batch with per-transaction merchant name + amount. Georgia’s processor exposes this through the EPPIC interface.

Privacy boundaries for recap:

  • Never include individual store locations narrower than city. "Kroger · Buford Hwy" is fine; "Kroger #18342, 1247 Buford Hwy" is not (locates the recipient).

  • Never include specific dates of high spending. Aggregate to month.

  • Sharing (the Share button on the outro slide) generates a redacted version — no name, no case ID, no city. Just the numbers and the case-year handle.

  • Opt-out must be honored throughout; if a recipient opted out of recap, no recap data is computed or stored.

  • Data minimization: recap data is recomputed at render time from existing case + transaction records; the rollups themselves are not stored as a precomputed table. Cheaper to compute on-demand than to maintain another store of derived sensitive data.

  • For Confidentiality::Confidential cases: the Household slide must show initials only ("J., M., A., M.") not full names. The Top Store slide must aggregate to city level only (no neighborhood/street hints). For AddressConfidential, all of the above plus no city information at all (state-level rollup only).

  • For ACP-flagged cases: recap entirely strips any neighborhood- or store-location information. The Top Store slide degrades gracefully to "Your most-shopped chain" without a location label.

6.12 RecoveryAttempt

struct RecoveryAttempt {
    id: AttemptId,
    started_at: DateTime<Utc>,
    case_id: Option<CaseId>,       // None until the identify step succeeds
    device_cookie: Option<DeviceCookie>,
    ip: IpAddr,
    user_agent: String,
    steps: Vec<RecoveryStep>,
    outcome: RecoveryOutcome,      // InFlight | Succeeded | LockedOut | Abandoned
    side_channel_sent: bool,
}

struct RecoveryStep {
    step: RecoveryStepKind,        // Identify | Address | History | Reveal
    at: DateTime<Utc>,
    correct: bool,
}

Stored for audit + anomaly review. Worker portal exposes a view of recent recovery attempts per case, with the failed-then-succeeded-from-a-new-device pattern flagged.

6.13 EligibilityRules and EligibilityEstimate

// Per-program, per-jurisdiction rule data shipped with the binary.
// SOURCE OF TRUTH for the server is a separate authoritative rules
// service — these client-shipped rules MUST be flagged guidance_only.
struct EligibilityRules {
    program: Program,                  // SNAP | Medicaid | TANF | WIC | ...
    jurisdiction: String,              // "georgia"
    effective_from: Date,              // COLA cycle start (typically Oct 1)
    effective_to: Option<Date>,        // next cycle
    guidance_only: bool,               // must be true for client-shipped sets
    income_limits: BTreeMap<u8, Money>, // household_size -> monthly gross cap
    deductions: DeductionRules,
    categorical: Vec<CategoricalRule>, // SNAP-auto-qualify if TANF/SSI/etc.
    expedited: Vec<ExpediteRule>,      // fast-track triggers
    special_tests: Vec<SpecialTest>,   // elderly/disabled net-income test, etc.
    messaging: MessagingTemplate,      // copy strings per tier, per language
}

// The output the rules engine produces from the in-progress application.
struct EligibilityEstimate {
    program: Program,
    tier: EstimateTier,                // Likely | Borderline | Unlikely
    gist: String,                      // 1-sentence headline
    why: Vec<EstimateReason>,          // shown in the "Show why" expander
    flip_hints: Vec<FlipHint>,         // actionable next questions
    pivots: Vec<DiscoverPivot>,        // adjacent programs if unlikely
    bypass: Option<BypassReason>,      // "categorical eligibility via TANF" wins
}

enum EstimateTier {
    Likely,      // comfortable margin OR categorical bypass triggered
    Borderline,  // within ~15% of the gate, OR likely-to-flip with deductions
    Unlikely,    // far over the gate even with deductions; pivot path
}

struct FlipHint {
    icon: String,         // matches ICON keys
    label: String,        // "Childcare or dependent care"
    sub: String,          // "Any amount you pay for daycare..."
    follows_to_step: u8,  // which apply step asks about this
}

The TOML form of EligibilityRules lives at rulesets/{jurisdiction}/eligibility/{program}.toml and is bundle-loaded at WASM init. See §4.5 for the canonical schema sketch.

7. API surface (sketch)

REST-shaped because Dioxus is happy with it. Authentication via the session token in an HttpOnly cookie + a paired CSRF token in a header (double-submit pattern).

Pre-auth

POST   /api/application                          → Create draft. Returns DraftCode.
PATCH  /api/application/:draft_code              → Update draft.
POST   /api/application/:draft_code/submit       → Final submit. Returns { case_id, passcode }.
POST   /api/auth/lookup                          → { case_id, passcode } → session.
POST   /api/auth/recover/identify                → { case_id | (name, dob, last4) } → { challenge_id }
POST   /api/auth/recover/answer                  → { challenge_id, step, answer } → { next | locked | success }
POST   /api/auth/recover/reveal                  → { challenge_id, captcha_token } → { passcode (one shot) }
POST   /api/auth/logout                          → Invalidate session.

Authed — case data

GET    /api/case                                 → Case (current state, programs, household, contact)
GET    /api/case/letters                         → List letters, paginated
GET    /api/case/letters/:letter_id              → Single letter
PATCH  /api/case/letters/:letter_id/read         → Mark read
GET    /api/case/documents                       → List documents on file
POST   /api/case/documents                       → Upload (multipart)
DELETE /api/case/documents/:doc_id               → Remove (only if status=Pending)
GET    /api/case/document_requests               → Open requests (drive Home banner)

Authed — flows

POST   /api/case/renewal                         → Submit renewal answers
GET    /api/case/renewal/prefill                 → Existing values to confirm against
POST   /api/case/change                          → Submit a ChangeRequest
GET    /api/case/messages                        → List messages in the thread, paginated
POST   /api/case/messages                        → Append a message (with optional attachments)
PATCH  /api/case/messages/:msg_id/read           → Mark a worker message read

Authed — extras

GET    /api/case/ebt                             → Current balance + last N transactions
GET    /api/case/ebt/transactions?since=...      → Paginated transactions
GET    /api/case/discover                        → Computed EligibilityHints for this case
POST   /api/case/discover/:program/interest      → One-tap interest toggle
GET    /api/case/recap?year=2026                 → Returns RecapData OR 404 if opted-out

Real-time updates

SSE on a single channel — /api/case/stream — emits events:

  • letter.new

  • document.requested / document.accepted / document.rejected

  • state.changed (caseState transitions)

  • message.new

  • interview.scheduled

  • ebt.deposit

WebSocket is overkill; long-polling is enough for the cadence (every 30s foregrounded, every 5min backgrounded if SSE isn’t supported).

8. Persistence boundaries

Lives in Examples

URL

Active screen, selected letter ID, apply step number, recap year

DB

Application, Case, Documents, Letters, Renewal, Change, Messages, RecoveryAttempts, audit events

indexedDB

Session token, language pref, theme pref, draft-form scratch, last-known nav state for fast restore, recap-seen-this-year flag

localStorage

(prototype only) — the implementation should graduate everything to indexedDB

Memory

Letter being read, current form state pre-save, recap slide index

URL params should support:

  • ?screen=home

  • ?screen=letter&id=NTC-2026-0921

  • ?screen=apply&step=3

  • ?screen=recap&year=2026

  • ?screen=recover

  • ?kiosk=1 (shared-device mode)

Deep links are essential for the helpline-shared-link pattern: a worker emails a link to a specific letter and the customer lands on it.

9. Customer ↔ Worker wiring

The two portals see the same data through different lenses.

Worker action Customer-side effect

Sends a letter (any category)

New row in Letters · unread badge bumps · push optional

Requests a document

Home action banner appears with the request copy

Marks an uploaded document accepted

The Files list row shows Accepted; no notification

Schedules a phone interview

caseStateInterviewScheduled; Home hero changes

Issues a decision

caseStateApproved / Closed; letter posted; hero

Closes the case

caseStateClosed; portal still accessible read-only

Re-opens / new app under same household

New Application, fresh credentials; old case stays in history

Sends a message in the thread

New row in Messages · unread count on Home quick-link tile · push optional

Replies to a change request

Status update in /api/case/change polling result · no Home banner unless adverse

Customer action Worker-side effect

Uploads a document

Worker’s "Pending verifications" queue gets a row

Submits a renewal

Worker’s renewal queue; auto-route to assigned worker

Submits a change-of-info request

Worker’s "incoming changes" queue; SLA timer starts

Sends a message

Worker’s thread inbox surfaces an unread; SLA: 2 business days

Toggles "Tell me more" on a Discover program

Surfaces in worker view as a soft signal (not actionable); analytics for partner-program take-up

Completes recovery successfully

Audit log entry on the case; side-channel notification fires (see §3.4)

Fails recovery (locked out)

Audit log entry + flagged for review if patterns repeat

Worker portal additions implied by this design (not yet in worker portal):

  • Customer-message inbox (separate from internal worker chat)

  • Change-request queue with SLA timer

  • Discover-interest analytics view (jurisdiction-aggregate)

  • Recovery-attempts review panel

10. Accessibility commitments

Same as worker, plus:

  • Tap targets ≥ 44 × 44 px everywhere. Currently met by Btn at md and lg sizes; the sm size is for desktop-only contexts.

  • Reading order matches visual order. Hero before programs before timeline before quick links.

  • Color is never the only signal. Status pills always have text. Required-form-field state never relies on red alone.

  • Reduced motion disables: the upload-progress animation, the case-state hero entrance, the EBT card flip transition (snap-cut instead), and the recap auto-advance (manual-advance only when prefers-reduced-motion: reduce).

  • prefers-color-scheme is respected on first paint when no cy-theme is saved. The prototype reads localStorage only; the implementation should fold prefers-color-scheme in as a default-only signal (a saved preference always wins).

  • Focus visibility — the prototype’s inputs use the accent gold for focus rings; ensure that’s present in Dioxus’s implementation of every interactive element, not just <input>.

  • Recap is keyboard-navigable — arrow keys advance, Esc closes (implemented in the prototype; preserve in the Dioxus port).

  • Card-flip — the EBT card flips on click/tap, but the same content is reachable via expand/collapse for keyboard users.

12. Hard rules for the implementation

IMPORTANT

These are non-negotiable. Several are also enforced or echoed by ADR-026. The numbering matches the source handoff’s §12 list.

  1. No localStorage for anything sensitive. Tokens go in indexedDB only.

  2. No client-side passcode strength checks. The server generates passcodes; clients only validate format.

  3. No "remember me." Period.

  4. No password fields on the apply form. The applicant doesn’t pick credentials.

  5. No mandatory email. Email is opt-in (for letter copies). The portal must work without it.

  6. No third-party fonts hosted by the implementation. Self-host Montserrat + JetBrains Mono.

  7. No analytics that send personal data off-site. First-party only.

  8. Every form is keyboard-completable. Tab order = visual order.

  9. Per-IP-primary rate limiting is forbidden. Cellular CGNAT — see §3.6.

  10. Recovery successful-attempt notifications are mandatory. The side-channel ping (email + SMS) is the cheapest fraud catch we have; don’t skip it for "noise reduction."

  11. No store-level granularity in recap. Aggregated to city or street name only (§6.11).

  12. No reCAPTCHA. Deanonymization concern for the audience. Use Turnstile or hCaptcha invisible.

  13. Confidential cases never get self-serve recovery. The recovery flow must check case.confidentiality before offering a single challenge. Phone or in-person only.

  14. Address is never used in challenge questions. Intimate threats know addresses. Don’t make that the security boundary (§3.7).

  15. No identity-data-only recovery path. SSN, DOB, name, and address are all things a current or former household member already knows. Self-serve recovery requires the Application ID + DOB at minimum; without the App ID, recovery is phone-only (§3.7).

  16. 24h delay on every successful recovery before passcode reveal. The window for the legitimate user to intercept must always exist (§3.7).

  17. Contact-info changes require dual confirmation + 24h cooldown. Never silently update phone or email (§3.10).

  18. Side-channel notifications go to the application-time contact as well as the current contact. A recently-changed contact is a possibly-controlled contact.

  19. "Get help" safety exit on every credential / contact / case-state flow. Routes to a screen with DV resources and a one-tap "hide this screen now" affordance (§3.11).

  20. Eligibility guidance never gates submit. Client-side rules produce a non-binding estimate to encourage more complete applications, never block them. The words "denied," "ineligible," "qualified," or "approved" are forbidden in client copy — the binding determination happens server-side after submit (§4.5).

Edit this page · default