Appearance
ENG-187 — Member Portal
Context
The calculator + plan-detail surfaces are live. A member who picks a plan on /plans/[planId] clicks "Continue to enroll" at PlanDetailHero.tsx:118-125 and currently dead-ends in PlanInterestModal — an email-capture placeholder. The member portal is the surface that replaces this dead-end.
The narrow MVP this plan targets:
- Collect the full ACA application data set (9 sections per the CMS Marketplace family application + EDE Year 9 technical reference) so a member can submit
- Save-and-resume mirror of the patterns already shipped in agent-onboarding/page.tsx and agent-discovery/page.tsx — same
StepKeymachinery, sameScreenShell, same "your progress is saved" chip, same auto-advance behavior. NO sync to HubSpot for member-portal data — PHI must not leave the AWS BAA boundary. All retention windows comply with docs/security-compliance/data-retention-policy.md - Surface the submission to an approved agent (read access only — full agent portal scope lives in a separate Linear issue; this plan only delivers the read-side seam)
- Land the member in the basic portal with their application status visible (submitted / pending agent review / submitted to Marketplace / DMI-pending / active)
Out of scope for this plan (tracked separately):
- Full agent queue + lead routing + auto-assignment + agent-side audit trail — separate "agent portal" issue
- Rich Florence integration (the long-term vision is a real-life AI health-insurance agent walking the member through the application step by step, NOT a chat widget bolted onto a wizard; design lands later, after the underlying basic flow is in)
- DMI/SVI document upload flows, drug + provider coverage check from portal, appointment booking, copay payment, renewals, insurance ID cards — staged Phase E+ once the basic submission loop works
- EDE-direct submission to FFM APIs (post-CMS approval, indefinite timeline) — Phase F
This is still a multi-quarter program in total but the first deliverable is narrow: member can complete a full application end-to-end, see submitted status in their portal, and an approved agent can see the lead.
Decisions locked this session
| Decision | Direction | Notes |
|---|---|---|
| Wizard shape | Typeform-style, one-question-per-screen, AI-native | Extend the existing pattern in agent-discovery/page.tsx — StepKey union, buildSteps(state) conditional builder, ScreenShell sticky question header, Typeform-style step machinery. Florence drives the conversational framing on each step. |
| Account timing | Frictionless capture at start | Email captured on the consent/intro screen (the first or second step), associated with an account immediately. Application doc keyed to account_id from creation. SSN is the actual privacy floor — once the user knows SSN is coming, email-up-front is not the friction barrier. Magic link goes out after submission to verify but does not gate the flow. |
| Florence prominence | Silent right-rail / bottom drawer | Florence is the conversational voice on each question screen (Playfair italic prompts in her voice) AND a collapsible side-panel for free-form questions. NO prominent "talk to Florence" diverging path at entry — the form IS the Florence experience. |
| EDE scope target | Full Phase 3 EDE coverage from day one | Phase B intake handles AI/AN attestations, non-tax-filers, non-spouse-non-child dependents, foster care history, all immigration statuses, pregnancy/students. Heavier Phase B; no second wave. |
| Mongo user model | 4-user functional model per ADR 0006 | ENG-279 (commit 96c6091) supersedes ADR 0003. No new narrow users for /enroll. Tenant isolation enforced at JWT-in-middleware + (Phase 5) Atlas views. |
| Subdomain split | /enroll, /portal, /a/* on app.askflorence.health | Per ENG-280 (commit ceebc7a). Cookie boundary cleanly separates marketing-tier 3P trackers (GA, Meta pixel, etc.) from PHI-tier portal. CSP on portal blocks all 3P trackers. The first-party self-hosted stack (OpenPanel + GlitchTip, ADR 0009) spans BOTH surfaces — under the AWS Org BAA, no analytics-vendor BAA; PostHog is retired. |
| Conversion attribution | Server-side CAPI on submission | fbclid/gclid/UTMs captured on apex, ride URL hop to portal, fire to Meta/Google CAPI from server post-submit with hashed email. Per Creative AdBundance brief. |
System architecture
Auth + identity
- Account model: one
accountscollection, keyed by email (lowercased). Magic-link first session; no password at launch. Mirrors the agent Tier 1 pattern at src/lib/agent-resume-token.ts — HMAC-signed token, single-use, 15-min TTL. - Session: HTTP-only cookie pointing at a server-side
member_sessionsrow (mirrorsagent_sessions). Idle 30 min, absolute 24 hr. Looser than agent profile because consumers only access their own PHI. - Account creation flow: member enters email on intro screen → server upserts
accountsdoc + createsmember_applicationsdoc keyed toaccount_id→ sets session cookie. No verification gate to start. Magic-link verification happens at submission time (verifies email is real before we submit to FFM) and on cross-device resume. - Future MFA seam:
member_sessions.factors[]array reserved so TOTP can layer in Phase F when post-enrollment surfaces expose claims/payment data (AAL2 territory).
Data model
One document per application — member_applications collection. Sub-documents per section because conditional logic across sections (e.g. "show pregnancy field if any member is female of childbearing age") requires reading the whole tree on every page load. Mongo 16 MB ceiling is irrelevant — max realistic application is ~50 KB.
Multi-member access model (forward-compatible, not all built MVP-1): an accounts collection holds one row per natural person, not per application. A family plan has one primary applicant who drives the flow, plus household members. Any adult household member may eventually claim their own login — life events like separation, divorce, or a young adult aging into independent coverage require it. The data model needs to support this from the start even though only the primary's flow control ships at MVP.
The shape:
accounts— one row per natural person. Identity: email + (later) verified identity bundle. An account may be:- primary on application A (full control: edit any field, submit, manage plan post-enrollment)
- co-adult on application B (read full application, edit their own personal-info fields, view post-enrollment status — no submission control)
- dependent-minor on application C (no direct access; parental control via the primary's account)
- dependent-adult on application D (limited, future — e.g. an adult child still on a parent's plan)
member_applications.householdMembers[]— each entry carriesrelationshipToPrimary,accountId | null(null = no separate login yet), and anaccessRole(primary|co_adult|dependent_minor|dependent_adult)- The primary applicant's
accountIdlives on the application doc asprimaryAccountIdfor fast indexing - Read authorization: any account that appears as primary OR as a household-member
accountIdcan read the application (with their role-scoped view) - Write authorization: only the primary's account can edit-anything; co-adults can edit their own member's personal-info fields only (a future Phase D / "co-adult portal" deliverable)
- Split/detach flow (future, design-reserved): if a co-adult wants to start an independent primary application (separation, divorce, age-out), the system clones the household-member's fields into a new
member_applicationsdoc keyed to their account as primary; the original retains an audit-onlysplitFrom: applicationIdreference. Not built MVP-1, but the data shape supports it without migration
member_applications {
_id, applicationId (uuid PK),
primaryAccountId (ref accounts), // the primary applicant's login
coverageYear, status, // draft | submitted_pending_agent_review | submitted_to_ffm | active | abandoned
selectedPlan { hiosId, premium, aptc, csr, ... }, // snapshot from /plans/[planId] handoff
calculator { zip, county_fips, income, householdSize, ... }, // URL-param handoff
sections {
primaryApplicant, household[], immigration[], income[],
coverage, sep|null, attestations, planSelection, payment
},
householdMembers[] { // forward-compat multi-account access
memberIndex, // 0 = primary, 1+ = others
accountId | null, // null = no separate login yet
accessRole, // "primary" | "co_adult" | "dependent_minor" | "dependent_adult"
relationshipToPrimary,
canEditSelf // bool; false MVP-1, opens up Phase D+
},
sectionStatus { primaryApplicant: "complete"|"partial"|"blank", ... },
consent[], // versioned, append-only, per src/lib/consent.ts shape
identityVerification { vendor, status, referenceId, completedAt },
florence { conversationId },
submittedAt, submittedBy { actor, agentId, method },
ffmStatus { status, dmis[], svis[] },
splitFrom: applicationId | null, // for future detach flow audit
schemaVersion: 1,
createdAt, updatedAt
}- Unique index:
{ primaryAccountId, coverageYear }— one application per household per coverage year, keyed by the primary - Secondary index:
{ "householdMembers.accountId": 1 }— lets a co-adult login resolve their household applications fast - Partial-save validation: per-section Zod schema with
.partial(); full-application schema only runs at submit - CSFLE field-level encryption on SSN, immigration document numbers, DOB per encryption-policy.md. Phase B requires this live; not optional
- MVP-1 behavior: every application has exactly one
accountIdpopulated (the primary). Other household members exist as data only, withaccountId: null. The co-adult login flow + access UI ships later but the schema slots are reserved now so no migration is needed when they do
Mongo users (4-user functional model per ADR 0006, supersedes ADR 0003)
No new per-feature narrow users. Per ENG-279 (commit 96c6091), the 10-user narrow-scope approach was rolled back after three silent regressions in one week. Defense-in-depth shifts up the stack: Phase 5 enforces per-tenant isolation via JWT-in-middleware + MongoDB views, not DB users.
Reuse the existing 4 users:
| User | Scope | Used by /enroll |
|---|---|---|
app_read (built-in read@askflorence) | DB-wide FIND | Plan lookup, eligibility re-check, county/zip reads |
app_write (built-in readWrite@askflorence) | DB-wide readWrite | accounts, member_applications, member_sessions, florence_conversations writes |
app_audit_writer (custom role_audit_writer) | FIND + INSERT on agent_audit_log only | Append every state transition + Florence interaction |
app_admin_schema (custom role_admin_schema) | Index mgmt on 3 collections (deploy-time only) | Schema rollout for new portal collections |
app_admin_schema may need its allowlist extended (Atlas role JSON) to include accounts + member_applications + member_sessions + florence_conversations before Phase A's ensure-indexes job runs.
Authorization at write time is enforced by the JWT in middleware (Phase A wires this): the session cookie carries account_id; every PATCH validates that the application doc's account_id matches the session's account_id. The Mongo write user has DB-wide write but the middleware narrows per-request to "your account only." Per-tenant Atlas views (Phase 5) layer an additional read-side guarantee for super-admin/auditor access patterns.
Subdomain split + Routes (ENG-280 marketing/portal separation)
Per ENG-280 (commit ceebc7a), the portal lives on a separate subdomain from the marketing site. This is enforced at the browser cookie boundary — a cookie set on the marketing apex cannot follow the user into the portal subdomain. The cut buys clean separation of marketing-tier analytics + 3P pixels from PHI-tier compliance.
| Surface | Host | Posture |
|---|---|---|
| Marketing apex | askflorence.health, www.askflorence.health | Calculator, /plans/*, landing pages, waitlist. First-party self-hosted analytics (OpenPanel + GlitchTip). 3P tools (GA, Meta pixel, etc.) OK after compliance review (Creative AdBundance precedent) |
| Portal | app.askflorence.health | /enroll/*, /portal/*, /api/enroll/*, /api/florence/*, /api/member/*. The first-party self-hosted stack (OpenPanel + GlitchTip) is the only analytics allowed — it crosses the marketing/portal boundary because it's first-party self-hosted, under the AWS Org BAA, and never sends data to a 3P. All other trackers (GA, Meta pixel, Google Ads, LinkedIn Insight, TikTok Pixel, etc.) are architecturally blocked via CSP. Zero session-recording on PHI tier. |
| Agent surface | app.askflorence.health/a/* (or sibling subdomain — defer to Phase C decision) | Agent auth, review queue, application read-only render. Same first-party-only / no-3P posture as portal |
Why the first-party stack is the exception: OpenPanel + GlitchTip are self-hosted on our AWS account, under the AWS Org BAA, never relay data to a third party, and don't fingerprint (ADR 0009). The marketing/portal cut is about third-party data egress, not about analytics in general. The first-party stack crossing both surfaces is desirable — it gives us a unified funnel from marketing-tier "viewed plans" → portal-tier "completed enrollment" without exposing PHI to any vendor outside AWS. GA, Meta pixel, and other 3P trackers send data to vendor-owned servers outside our BAA boundary; those stay strictly on apex.
Routing infrastructure: Both subdomains route through the same CloudFront → ALB. ALB listener rules dispatch by Host header. Route 53 points both hosts to the same CloudFront distribution. Cert (ACM) covers both via SAN. CSP header is added at the ALB or CloudFront response-headers-policy layer, scoped to Host = app.askflorence.health.
Handoff across subdomains: When a member clicks "Continue to enroll" on askflorence.health/plans/[planId], the click navigates to https://app.askflorence.health/enroll?plan=[id]&... carrying the household context via URL params (the existing useCalculator().browseAllPlansUrl shape). The sessionStorage af_household_people does NOT survive the cross-origin hop — server-side recompute from URL params is the only path. Phase A's POST /api/enroll/applications must accept URL params alone as sufficient input.
Document this pattern explicitly for the future native mobile app. When iOS/Android lands, the same constraint will apply — the marketing app and the portal app must be considered separate origins/identity boundaries even though they share branding. The mobile app will invoke a /enroll/start deeplink or its own native entry point; either way the constraint is the same: the portal entry endpoint must accept a self-contained payload (URL params or POST body) and re-derive eligibility server-side. Phase A's POST /api/enroll/applications is the canonical entry point for ALL future surfaces (web, iOS, Android, agent-on-behalf-of). Land this pattern in docs/architecture/portal-entry-handoff.md (new doc, Phase A deliverable) so the iOS engineer who shows up in 2027 inherits the design intent instead of reverse-engineering it.
Conversion attribution (Creative AdBundance pattern, per docs/briefs/creative-adbundance-analytics-brief.md): click IDs (fbclid, gclid, UTMs) captured client-side on apex into sessionStorage. On /enroll handoff, they ride in URL params (subset) and are stored on the application doc. On submission, the server fires CAPI calls to Meta/Google with hashed email + original click IDs. No client-side pixels on portal. No PostHog. This is the architectural template for any future 3P conversion tooling on the portal.
Member-facing (Next.js app router, server actions for form submits) — all on app.askflorence.health:
/enroll— entry, consent intro, email capture, application creation/enroll/[applicationId]/step/[stepKey]— Typeform-style one-question-per-screen wizard.stepKeymirrorsStepKeyin agent-discovery/page.tsx:267/enroll/[applicationId]/review— full read-only summary + typed-signature + perjury attestation/enroll/[applicationId]/submitted— confirmation + what-happens-next/portal— authenticated member dashboard. First deliverable scope: shows submitted-application status (submitted / pending agent review / submitted to Marketplace / DMI-pending / active). Pre-submission, redirects to in-progress application's resume URL. Drug-coverage check, provider-network check, ID cards, appointments, copays, renewals — all staged later/portal/login+/portal/verify— magic-link entry
Agent-facing (intentionally narrow — full agent portal is a separate Linear issue):
The only thing this plan delivers on the agent side is a read-side surface that lets an approved agent see a submitted application. Queue, routing, lead-assignment, agent-side audit trail (did the agent submit directly or have to interact with the member?), agent activity scoring, broker-of-record tracking — all out of scope here. Build a minimal /a/applications/[applicationId] read-only render that an approved agent can hit; the surrounding queue + assignment scaffolding is reserved for the agent-portal initiative.
/a/applications/[applicationId]— read-only application render, approved-agent-gated. The component re-uses the wizard'sScreenShellviews inreadOnlymode. The "Submit via HealthSherpa" handoff button is wired here as a Phase C deliverable but the surrounding workflow (queue + assignment + outcome tracking) is the agent-portal issue's problem.
API routes (all behind Host = app.askflorence.health):
POST /api/enroll/applications— create from plan-select handoff (accepts URL params only — no cross-origin sessionStorage)PATCH /api/enroll/applications/[id]/section/[name]— per-section partial savePOST /api/enroll/applications/[id]/submit— validate + transition tosubmitted_pending_agent_review; fires server-side CAPI conversion event with hashed email + stored click IDsPOST /api/florence/message— Phase D, server-side proxy to Anthropic/Bedrock with tool-usePOST /api/member/auth/{request,verify,logout}— magic-link flowPOST /api/a/applications/[id]/{approve,return}— agent actions, appendagent_audit_log
Next.js app structure decision (defer to Phase A kickoff but recommend now): single Next.js app deployed to one ECS service, route gating by host. Routes for marketing (/, /plans/*, etc.) refuse to render under app.* host (middleware 404); portal routes (/enroll/*, /portal/*) refuse to render under apex (middleware 404). Single codebase, single deploy, single ECR image. Alternative — two Next.js apps in a monorepo — is more isolation but doubles deploy infra and complicates the calculator → enroll handoff. Recommend single-app + host-gating middleware.
Sensitive data handling (SSN, immigration docs, DOB, financial info)
This needs its own focused design pass before Phase B sections that collect SSN/immigration/payment data land. The MVP plan must answer — and the answers must be documented in docs/security-compliance/sensitive-data-handling-member-portal.md (new doc, deliverable as part of Phase A) and reflected back on the Linear ENG-187 issue:
- Storage — SSN, immigration document numbers, DOB at minimum. CSFLE field-level encryption per encryption-policy.md with KMS-CMK-derived data keys. Encryption is enforced at the driver layer (mongocryptd /
auto_encryption_options), not at the application layer — meaning even direct Atlas queries byapp_writereturn ciphertext for those fields. Define the key-rotation cadence. - In-transit — TLS 1.2+ everywhere. Internal VPC traffic also TLS (no plaintext on the wire even within our VPC).
- Presentation — what does the member see in their own portal once SSN is captured? Two reasonable answers: (a) masked
***-**-1234everywhere except in the moment of entry, with no read-back; (b) re-prompt for SSN at any moment the member wants to see/edit it (verification step-up). Pick (a) + step-up-on-edit as the default and document the rationale. - Step-up verification before reveal — design the verification challenge for any operation that exposes sensitive data: re-enter password / re-do magic link / re-do TOTP if MFA is on / answer challenge question. This is required for ANY portal read that exposes SSN or full DOB or immigration document numbers.
- Logging discipline — application logs MUST never include sensitive field values. Define a deny-list at the structured-logger layer; CI check scans logger calls for the deny-listed property names.
- Backup + restore — encrypted backups stay encrypted; restore path requires Atlas admin + KMS-CMK access. Document the dual-control restore procedure.
- Egress controls — sensitive fields never appear in: HubSpot sync (already excluded), analytics events (OpenPanel: only sanitized event names + bucketed values, never raw income/PII), email content (SES body templates have no PHI), webhook outbound payloads.
- What we present to user vs hide entirely — answer per field. SSN: hide (masked-only). Immigration doc number: hide (masked-only). DOB: show in full (it's already on every form they filled). Income amount: show (member already knows). Address: show. Phone: show. The cut is: anything that maps to a high-risk identity-theft vector goes hidden post-entry.
- Audit trail — every sensitive-field read (even by the member themselves) appends to
agent_audit_logwith eventmember_sensitive_field_accessed. Auditor can reconstruct: who saw what, when. - Data retention — application data retained per the policy in docs/security-compliance/data-retention-policy.md. Define for member-portal specifically: abandoned drafts (TTL?), submitted-but-rejected applications (retain for FFM re-submission?), active member data (retain through coverage year + 7 years for tax), terminated coverage (retain for re-enrollment year + 7).
The deliverable is the new doc + an updated section on the ENG-187 Linear issue + a plan-of-record on how each Phase B field-collection step hooks into the patterns above. The doc lands BEFORE any code is shipped that writes a real SSN.
Florence integration architecture (deliberately TBD)
Florence is not a widget bolted onto a wizard. The long-term vision is a real-life AI health-insurance agent walking the member through the application step by step, helping each step of the way — closer to "you're talking to a knowledgeable agent who happens to be operating a CMS-compliant submission system on your behalf" than to "the chat panel on a SaaS app."
We deliberately leave Florence's UX shape flexible at this plan-of-record stage. Build the underlying basic flow first (Phases A-C). When Florence's design lands, the underlying primitives — per-section Zod schemas, PATCH server actions, tenant-isolated application doc, conversation persistence collection, audit log — are the same. The same member_applications write path serves a human-operating-a-wizard, a Florence-operating-on-behalf-of-the-member, and an agent-operating-on-behalf-of-the-member.
What we lock in now (so future Florence design isn't blocked):
- New collection
florence_conversations, append-only, referenced frommember_applications.florence.conversationId. Schema TBD; reserve the slot - Server-side proxy pattern:
anthropic-api-keyand Bedrock role ARN never reach the browser; all LLM calls go through a Fargate-hosted route - Tool-use schemas (when authored) use generic ACA-vocabulary property names; PHI lives in values, never schema property names or enums (per the structured-outputs HIPAA constraint)
- Per-interaction audit-log entry to
agent_audit_log
What we deliberately defer:
- Whether Florence is a side-panel, full-takeover chat, voice-first, multimodal — open
- Whether the wizard "wraps around" Florence or Florence "wraps around" the wizard — open
- Whether members get a choice of mode or Florence is the default — open
- How Florence handles failure modes, confidence thresholds, ambiguity, fallback to structured prompts — open
- Conversation persistence schema beyond "append-only doc per conversation" — TBD when the UX design lands
Agent-assist seam (deliberately minimal — full agent portal is a separate issue)
The seam this plan delivers is narrow: when a member submits, an approved agent must be able to see the lead.
- Member submits → application doc transitions to
status: submitted_pending_agent_review - SES notification to
agents@askflorence.healthgroup inbox (Phase C; agent-side assignment + per-agent routing is the agent-portal issue's scope) - An approved agent (gated by the same Tier-1 / Tier-2 auth the agent platform ships) can hit
/a/applications/[applicationId]and see the read-only render
Out of scope here: agent queue UI, per-agent assignment + routing, "did the agent submit directly or interact with the member" tracking, broker-of-record bookkeeping, agent activity scoring, full audit of agent actions beyond the basic agent_audit_log entry on read access. All of those land in the agent-portal issue.
Submission abstraction (lock in now, implement later): submitApplicationToFFM(applicationId, strategy) with strategy: "agent_handoff_healthsherpa" | "agent_handoff_healthcaregov" | "ede_direct". Same UI flow in all worlds; only the submission target swaps. The strategy is invoked from the agent surface (the agent-portal issue owns the button + workflow); ENG-187 just stubs the function signature + the underlying data shape it needs.
Phasing
Each phase is 1-3 PRs with a defined seam. Code can be written + reviewed before AWS cutover; no PHI write deploys until AWS migration is complete (per #47) AND CSFLE is live AND #56 narrow Mongo users are provisioned.
Phase A — Foundation + subdomain
Ships:
- Infra:
app.askflorence.healthprovisioned (Route 53 + ACM SAN + CloudFront cache behavior + ALB listener rules dispatching by Host header). CSP response-headers-policy on portal Host:script-src 'self' <first-party-analytics-host>;connect-src 'self' <first-party-analytics-host>(= self-hosted OpenPanel + GlitchTip); no GA / Meta pixel / Google Ads / LinkedIn / TikTok / any other 3P tracker host in the allowlist - Mongo:
accounts+member_applications+member_sessions+florence_conversationscollections viaapp_admin_schema(allowlist extended to these 4 names). Indexes per the data-model section - Auth: magic-link send via src/lib/email.ts; session cookie scoped to
app.askflorence.health; JWT middleware enforcingaccount_idmatch on every PATCH - App:
/enrollentry route, consent intro, email capture, application creation. Empty/portalshell. Marketing/portal host-gating middleware (refuses to render mismatched routes) - Cross-origin handoff:
PlanDetailHero.tsx:123rewired to navigate tohttps://app.askflorence.health/enroll?plan=[id]&{calculator URL params}. Click IDs (fbclid, gclid, UTMs) piggyback in the URL - Server endpoint:
POST /api/enroll/applicationsaccepts URL params alone (no cross-origin sessionStorage); server re-derives eligibility via fetch-plans.ts if needed - One stub Typeform step (identity name) wired through PATCH server action so the infra is end-to-end
Seam: StepKey enum + buildSteps(state) + ScreenShell + PATCH server action all in place. Adding a section becomes: (1) extend StepKey union, (2) add Zod schema, (3) add ScreenShell-wrapped form component, (4) add to buildSteps.
Blocking deps: #55 privacy/terms publish; #58 consent versioning (adds MEMBER_APPLICATION_INTAKE key to src/lib/legal-versions.ts). #56 is no longer a dependency — superseded by ENG-279/ADR 0006. OpenPanel portal-tier event stream (per ENG-280 timeline mid-May → mid-June) should be wired before Phase B opens to prod.
Phase B — Full Typeform intake (Phase 3 EDE scope)
Ships: all 9 sections, every conditional branch, including AI/AN, non-tax-filer, exotic dependent flows. Each step models on agent-discovery + agent-onboarding patterns (same StepKey machinery, ScreenShell, "Your progress is saved" chip, auto-advance for pre-filled steps, conditional follow-up steps). Optimistic save on blur (debounce 500ms). Save-and-resume via magic link. Mobile takeover (lifecycle pattern from LandingCalculator.tsx takeover-bar). ID verification vendor adapter (vendor TBD — same vendor as agents preferred). CSFLE encryption on SSN/DOB/doc-numbers. Sensitive-data-handling doc (per the section above) is a Phase B blocker. No HubSpot egress for any member-portal data.
Phase B also captures the "additional enrollment questions" surface within ENG-187 scope (not a sibling issue): the calculator is deliberately minimal (ZIP, ages, income, household size) but enrollment must ask everything the FFM needs PLUS the inputs that affect pricing the calculator skipped. Most notably tobacco surcharge per member — the calculator does not model this; the enrollment app must collect it and re-price the selected plan before submission. The Section 8 (Plan selection + APTC allocation) screen is the natural place: when the tobacco answer changes, the displayed premium updates in real time, and the final submitted price reflects the surcharged rate. This becomes a sub-deliverable of Phase B, not a separate Linear issue.
Seam for C: completed application docs in submitted_pending_agent_review state. Agent surface reads them.
Phase C — Agent read access (narrow; full agent portal lives elsewhere)
Ships: /a/applications/[id] read-only render gated by approved-agent auth. SES notification on submission to the agents group inbox. agent_audit_log insert on agent's first view of the application. Out of scope: queue UI, per-agent assignment, lead-routing logic, "Submit via HealthSherpa" handoff workflow, agent activity tracking — all reserved for the agent-portal issue. The seam Phase C provides is "an approved agent can see a submitted lead." Everything else is the agent-portal initiative's problem.
Adjustable scope: anticipate that the agent-portal issue may want to restructure how applications surface to agents (different URL, different listing, etc.). Keep the read-only render component independently composable so it slots into whatever the agent-portal flow ends up being.
Blocking deps: agent auth Tier 1/Tier 2 live (separate issue).
Phase D — Member-side application status surface
Ships: authenticated /portal lands the member on a status view of their submitted application. Status enum surfaced: submitted_pending_agent_review, submitted_to_ffm, dmi_pending, svi_pending, active, denied. Simple editorial card per submitted application. No DMI/SVI document upload yet, no drug coverage check, no provider check, no ID card — just status. This is the deliverable that closes the loop on "member fills application and is in portal where they can see status."
Seam for E: status state machine is live; further surfaces extend the dashboard, not replace it.
Phase E — DMI/SVI dashboard, document upload, lifecycle features (later)
Ships eventually: DMI mismatch detail views; SVI verification tracker; document upload (S3 presigned, KMS-encrypted, per encryption-policy.md); drug formulary check per active plan; provider/network check; ID card view; copay payment; renewals. Each becomes its own focused effort on top of the Phase A-D foundation. Florence layers in here in earnest as the rich integrated experience (not a widget).
Phase F — EDE-direct submission (post-CMS approval, indefinite timeline)
Ships eventually: server-side FFM API call replaces the agent-handoff strategy in submitApplicationToFFM. Agent-review step retained as a SOC 2 + EDE Year 9 control even when submission is automated.
Information architecture — Typeform-style flow
The wizard is one question per screen. Each screen extends the pattern at agent-discovery/page.tsx:267:
- Sticky top strip (40 px) — lantern mark + AskFlorence wordmark + "Save & exit" tertiary text-link. Mobile-takeover (no nav, no footer)
- Plan context bar below — issuer + plan name + price; expand-inline accordion on mobile, sticky right-rail card on desktop ≥ 1024 px
- Progress chip — "Chapter 2 of 4 — Income & coverage" + 9-dot subprogress rule
ScreenShellbody — eyebrow + Florence's question in Playfair italic + helper text in Inter + structured input + "Saved" fade caption on blur- Sticky bottom CTA — "Continue" (ink/white, 4px radius, safe-area-inset padding-bottom)
- Tertiary "Ask a question about this" link — opens Florence side panel scoped to this step
Chapter wrapping (so the member sees structure, not 30+ atomic screens):
- Chapter 1. About you — identity, household composition, citizenship/immigration
- Chapter 2. Income & coverage — income sources, deductions, current/offered coverage, SEP (only outside open enrollment)
- Chapter 3. Confirm & sign — attestations grouped into 3 editorial cards, APTC + tobacco
- Chapter 4. First premium — payment
Conditional branches per the Phase 3 EDE scope all flow through buildSteps(state) — citizenship-status answer pushes/pops immigration screens; coverage-section answer pushes/pops employer-coverage screens; outside-OE date pushes SEP screens; etc.
Mobile — full-viewport takeover identical to the calculator pattern. Sticky bottom CTA pinned with padding-bottom: calc(14px + env(safe-area-inset-bottom)). Native keyboard hints: inputmode="numeric" for SSN/EIN/$/DOB, autocomplete tokens throughout, type="tel" for phone, type="email" for email.
Visual register — extends docs/design-system.md: cream paper (--af-paper #FBF6EB) backgrounds; soft-inset cream-on-paper inputs; Playfair italic-gold accents on Florence's question phrasing; 4px radii; no rounded pills; no em-dashes. Per-step composition lives at a new src/app/enroll/[applicationId]/step/[stepKey]/page.tsx with src/app/enroll/enroll.css for af-enroll__* namespace.
Critical files to extend (not duplicate)
Handoff seam to flip:
- src/components/plans/detail/PlanDetailHero.tsx:118-125 — flip "Continue to enroll" from
setInterestOpen(true)to navigation to/enroll?plan=[id] - src/components/plans/detail/PlanInterestModal.tsx — retire on the same PR that ships Phase A
- src/lib/hooks/use-calculator.ts — add
enrollUrlbuilder sibling tobrowseAllPlansUrl
Pattern sources to extend:
- src/app/agent-discovery/page.tsx — canonical Typeform-style step machinery. Lift
StepKeyunion,buildSteps(state),ScreenShell,SECTION_FOR_STEP,validateStep, sticky question header, "your progress is saved" chip, auto-advance pattern - src/app/agent-onboarding/page.tsx — repeater pattern reference
- src/lib/agent-db.ts — narrow-scope Mongo user with env-var fallback chain (the canonical reference)
- src/lib/agent-resume-token.ts — HMAC token pattern
- src/lib/consent.ts + src/lib/legal-versions.ts — consent capture; add
MEMBER_APPLICATION_INTAKEkey - src/lib/email.ts + src/lib/email-template.ts — SES send + RFC 8058 unsubscribe (reuse for magic-link send + submission confirmations)
- src/app/api/waitlist/route.ts — route handler shape (consent capture + rate limit + idempotent upsert + audit-log)
- src/app/_home/components/LandingCalculator.tsx — mobile takeover lifecycle (
position: fixed,html.style.overflow = "hidden",history.pushStatesentinel, focus trap, Escape handler)
Architecture references:
- docs/adr/0002-append-only-audit-log.md
- docs/adr/0006-mongo-user-simplification.md — supersedes ADR 0003; 4-user functional model
- docs/briefs/SESSION_BRIEF_ENG-279_mongo-user-simplification.md
- docs/briefs/creative-adbundance-analytics-brief.md — subdomain split + server-side CAPI pattern
- docs/security-compliance/marketing-vs-portal-analytics.md — ENG-280 control doc
- docs/agent-platform/auth.md — Tier 1 magic-link pattern to mirror
- docs/briefs/BRIEF_FLORENCE_AI_ARCHITECTURE.md
- docs/security-compliance/ede-control-mapping.md
- docs/security-compliance/encryption-policy.md
- docs/security-compliance/data-retention-policy.md
Research brief (already on disk):
~/.claude/plans/eng-187-member-portal-it-s-squishy-prism-agent-a6787992491f05651.md— 9-section data scope, HealthSherpa flow narrative, AI form-fill guidance, source URLs
Risks + open items
- HealthSherpa partnership timeline — Phase C agent submission depends on HealthSherpa partner relationship. Mitigation: abstraction takes
strategyparam; falling back to healthcare.gov broker portal is a config change - Schema migrations once members have draft applications — partial-state save shape is expensive to change in flight. Mitigation: spend Phase A time on per-section Zod schemas; version every doc with
schemaVersion: 1 - Florence conversational ACA pre-fill is unproven — no public reference implementations. Mitigation: Phase D ships as overlay assist, not replacement. Iterate based on usage
- EDE approval timeline (CMS) opaque — 6-18 month variance. Mitigation: Phase C handoff architecture works indefinitely; EDE-direct is an optimization
- PHI deploy discipline — every PHI write extends the SOC 2 + EDE audit window backward. We are fully on AWS now; the cutover-era warning about "no PHI on the legacy host" is no longer relevant. Hard rule today: PHI writes only deploy when the sensitive-data-handling doc is signed off + CSFLE is live + OpenPanel portal-tier event stream is live
- ID verification at the wrong moment kills conversion — front-loading at /enroll entry bleeds funnel; back-loading discovers failure after the work is done. Mitigation: verify async at end of identity step; if it fails, surface recoverable retry before they leave Chapter 1
- Cross-origin handoff fragility — sessionStorage cannot cross from
askflorence.healthtoapp.askflorence.health. Mitigation: Phase APOST /api/enroll/applicationsaccepts URL params only and re-derives eligibility server-side via existing fetch-plans pipeline. Document the cross-origin behavior so future surfaces don't regress - Pre-account email-capture posture — capturing email before SSN means we have member email at high partial-completion rates. Implication: ANY abandoned application is a marketing-eligible record (with consent capture). #59 unsubscribe flow must land before Phase A goes to prod
- Authorization-in-middleware is the only tenant guard at Phase A — without per-feature Mongo users (per ADR 0006), every PHI read/write rides DB-wide
app_write. Middleware bug = cross-tenant leak. Mitigation: (a) every PATCH/GET on/api/enroll/*and/api/portal/*validatessession.account_id === doc.account_idbefore touching the doc; (b) Phase A includes a "tenant isolation" test suite (e.g. session for account A cannot read application B); (c) Phase 5 layers Atlas views as a second-line guarantee for super-admin/auditor read paths - CSP misconfiguration on portal — the architectural assumption "no 3P scripts on portal" is enforced by a Content-Security-Policy header. If it's set as report-only or scoped incorrectly, pixels can sneak in via component imports. Mitigation: Phase A ships the CSP in enforcing mode from day one; Phase B adds a CI check that scans portal-route source files for
<script src=/ window-namespace pollution - Conversion attribution loss on subdomain hop — fbclid/gclid/UTMs captured on apex must ride the URL hop intact. Long URLs may be truncated or stripped by proxies. Mitigation: the Creative AdBundance brief at docs/briefs/creative-adbundance-analytics-brief.md is the spec; Phase A wires it; verify in staging with a full ad-platform → calculator → /plans → /enroll round-trip
- OpenPanel portal-tier event stream not live yet — per ENG-280, OpenPanel self-hosted is mid-rollout (mid-May → mid-June 2026). Phase A cannot ship to prod without a working analytics path on portal. Mitigation: sequence Phase A code-merge ahead of OpenPanel; gate prod deploy on OpenPanel ready
Critical decisions deferred
These don't block plan acceptance but need answers before the corresponding phase:
- Florence provider — Anthropic Direct API (faster, BAA already likely in motion) vs AWS Bedrock (more plumbing, already partially scaffolded per infra/envs/prod/secrets.tf, and is the long-term Phase 3 destination). Pick when Florence UX design lands
- Submission target — HealthSherpa vs healthcare.gov broker direct vs both. Picks at agent-portal-issue kickoff, depends on Ian's partner conversation
- ID verification vendor — Persona / Stripe Identity / Plaid / Veriff. Pick before Phase B; aligning with agent vendor reduces BAA + vendor-register work
- Geographic launch scope — likely all FFM (federally facilitated marketplace) states based on user direction; confirm at Phase B kickoff
- Tobacco surcharge + other enrollment-time-only questions — calculator is intentionally minimal; tobacco surcharge and any other inputs that affect plan pricing but aren't captured up-front belong in enrollment. Scoped INSIDE ENG-187 Phase B (not a sibling issue): Section 8 (Plan selection + APTC allocation) collects tobacco use per member and re-prices the selected plan in real time. Audit which pricing-affecting fields the calculator skipped before Phase B build kicks off.
- Payment method scope — offer a variety (card + ACH + other methods as a baseline). PCI scope expansion is accepted; vendor decision (Stripe / Square / direct bank rails) lands at Phase B kickoff
- Sensitive-data-handling doc sign-off — the new docs/security-compliance/sensitive-data-handling-member-portal.md (Phase A deliverable) must be reviewed + signed off before Phase B sections touch SSN/immigration/payment data
Verification
End-to-end verification per phase:
Phase A:
npx tsc --noEmitcleanscripts/audit/calculator-baseline-diff.ts→ ZERO DIFFS (handoff change must not regress calculator math)- Subdomain provisioned:
dig app.askflorence.healthreturns CloudFront;curl -I https://app.askflorence.health/returns 200; ACM cert is valid; ALB Host-header dispatch verified - CSP enforced on portal:
curl -I https://app.askflorence.health/enrollincludesContent-Security-Policyheader that allows OpenPanel's self-hosted host but blocks GA / Meta / other 3P trackers; attempting to inject a<script src="https://www.googletagmanager.com/...">fails in browser console - Host-gating middleware:
https://app.askflorence.health/plansreturns 404;https://askflorence.health/enrollreturns 404 (or redirects to apex equivalent) - Cross-origin handoff: click "Continue to enroll" on
askflorence.health/plans/[planId]→ land onapp.askflorence.health/enrollcarrying URL params including any click IDs → enter email → application doc visible in Mongo withaccount_idset + session cookie scoped toapp.askflorence.healthpresent - Mongo direct query:
db.member_applications.findOne({ applicationId })shape matches Zod schema;account_idpopulated; click IDs preserved on doc - Tenant isolation: session for account A cannot GET/PATCH application B (middleware test suite)
- SES send for magic-link verified
- OpenPanel events flow from portal —
enroll_entry_viewed,enroll_email_captured,enroll_step_completed(OpenPanel self-hosted spans marketing + portal as a unified funnel; no PostHog, no GA, no Meta pixel on portal) - WAF + CloudFront route the new endpoints without 403
Phase B (per section as they land):
- Manual: traverse a representative scenario (single US citizen W-2; married couple with non-citizen spouse; household with AI/AN child claiming cost-sharing exemption; non-tax-filer; outside-OE applicant with SEP loss-of-MEC)
- Verify CSFLE: encrypted field comes back encrypted in a non-app-user Atlas query
- Each section's Zod schema fuzz-tested against the canonical CMS family application PDF fields
- Mobile takeover lifecycle verified at 393×852 — sticky CTA below safe-area-inset, focus trap, Escape exit
Phase C:
- Agent receives SES notification within 60s of member submission
/a/applications/[id]renders read-only view; HealthSherpa handoff URL constructed;agent_audit_logrow appears per state transition- audit_reader user can
db.agent_audit_log.find()and see the full history
Phase D:
- Florence side panel opens, sees current application state, can extract a field via tool call, returns to the form
- Per-field "Florence filled this" affordance renders; one-click revert works
florence_conversationsdoc appended;agent_audit_logrows per Florence interaction- PHI absent from tool schema property/enum names (audit by scanning the schemas at build time)
Phase E: DMI/SVI surfaces render from member_applications.ffmStatus; document upload writes to S3 with KMS encryption; presigned URL expires correctly.
Phase F: EDE-direct submission round-trips through FFM stub in staging; agent-review step retained even with automation.
What lands first
Phase A breaks into two PR clusters:
PR cluster 1 — Infra (no app code):
- Terraform:
app.askflorence.healthRoute 53 record + ACM SAN + CloudFront cache behavior with portal-scoped response-headers-policy (CSP) + ALB listener rule for Host =app.askflorence.health - Mongo: extend
app_admin_schemaAtlas role to include the 4 new collection names; ensure-indexes job adds indexes foraccounts.email,member_applications.{account_id, coverageYear}unique,member_sessions.tokenunique,florence_conversations.applicationId
PR cluster 2 — App:
PlanDetailHeroCTA rewire tohttps://app.askflorence.health/enroll?plan=[id]&...with click-ID + URL-param payload/enrollentry route (host-gated to portal) + consent intro + email capture + magic-link send (cross-device resume)- Host-gating middleware (refuses cross-host route renders)
- JWT session cookie scoped to portal host + per-request tenant guard on PATCH/GET
- One stub Typeform step (identity name) + PATCH server action
- OpenPanel event wiring on portal routes
Deploy gate + flow (note the inversion): code merges to main; we test locally; deploy to prod next to verify; only after approval does it promote to staging. This is the opposite of standard pipelines and is the current rule because staging is shared with YCombinator and cannot regress — we have no customers and aren't launched, so prod is the safe-to-iterate environment while staging stays clean. Production deploy gated on: AWS portal stack live (subdomain provisioned, CSP enforcing, Host-header routing correct) + CSFLE for SSN/DOB/doc-numbers + sensitive-data-handling doc signed off + #55 privacy/terms + #58 consent versioning + #59 unsubscribe + OpenPanel portal-tier event stream live (per ENG-280 timeline). #56 is no longer in this gate (superseded by ADR 0006).