Appearance
Session cookie architecture (ENG-322)
Server-side marketing session that replaces query-param + sessionStorage state-passing between the calculator and /plans. Source: ENG-286 audit finding M5 (privacy framing), expanded after the 2026-05-14 founder review into a four-win architectural shift: anti-scraping moat, privacy positioning, mobile-ready backend, and a Phase 5 PHI-store the member portal inherits rather than rebuilds.
Linear: ENG-322.
Why
Today the calculator hands state to /plans via the URL:
/plans?zip=84094&income=21000&married=true&county_fips=49035&aptc=1031&csr=94&household_size=2Two problems:
- State leakage —
income/aptc/csrride Referer headers, browser-history sync, CloudFront + ALB access logs, and analytics URL capture. - Scrape surface — a competitor can iterate the parameter space directly and rebuild our subsidized-pricing map within hours (the exact technique we used against state-based exchanges, pointed inward). Server sessions + opaque per-session plan IDs + the ENG-321 per-IP rate limit together make that materially more expensive.
Rollout — two phases, sequenced
This is the conversion-critical surface. It ships in two deliberately separated phases, mirroring the sequenced discipline that caught the ENG-323 Edge-runtime breaker before it could enforce.
Phase 1 — backend infrastructure (shipped, dormant)
Everything server-side, wired to nothing in the default flow:
marketing_sessionsMongo collection + TTL index (expiresAt,expireAfterSeconds: 0, +7 days) via the existing deploy-timeensure-indexesECS RunTask — no new infra, no new secrets (app_writealready has DB-widereadWriteper ADR 0006).src/lib/marketing-session.ts— session lib (types, ID + opaque-token derivation, CRUD, transport-agnostic resolver, backward-compat helper).- Four guarded routes under
src/app/api/session/*(init,GET /api/session,coverage,coverage/clear).
useCalculator(), /plans, /plans/[planId] are unchanged. The calculator → URL → /plans flow is byte-identical to before. The calculator-baseline-diff gate is trivially ZERO DIFFS because the audit-locked fetchPlansForHousehold pipeline is never touched. The new routes are live and verifiable but no production user reaches them.
Phase 2 — surface cutover (gated, watched)
Wires useCalculator() + /plans + the new /plans/p/<opaqueId> detail route to the session API, gated on the plain (NOT NEXT_PUBLIC_) SESSION_FLOW_ENABLED env var:
src/lib/session-flag.ts— client-safeSESSION_FLOW_ENABLED(nonode:crypto), the single source of truth;marketing-session.tsre-exports it. Read server-side at runtime (aNEXT_PUBLIC_var would be frozen into the client bundle atnext buildand defeat the runtime flip).- The boolean is delivered to client components via
src/lib/session-flag-context.tsx, never a clientprocess.envread, via two providers on the same context:- Dynamic pages (
/plans,/plans/p/*— they callcookies()):<SessionFlagProvider enabled={SESSION_FLOW_ENABLED}>— a runtime server read. - Static pages (home
/,/landing-1,/restaurant-workers-*— no dynamic API → Next renders them static, so a server flag read is frozen atnext build):<SessionFlagAutoProvider>fetches/api/session/flag(aforce-dynamicroute handler — runtime value,no-store) on mount. Defaults false until it resolves (a fast submit gracefully uses legacy; the flag is known within the seconds it takes to fill the form). Keeps the marketing pages static (no perf regression) AND runtime-flippable. (This static-render freeze was caught by the watched-cutover Playwright run — the calculator kept emitting legacy params URLs because the build-time server read was false; the auto-provider is the fix.)
- Dynamic pages (
- Calculator (
useCalculator({ sessionFlowEnabled })): on pipeline success, POST/api/session/initand, if it succeeds, the next-step CTA becomes a clean/plans(no params). On session-POST failure the CTA stays on the legacy params URL — graceful degradation. /plans(server): valid session cookie → render from session (clean URL); else fall through to the params path (old links keep working)./plans/p/<opaqueId>(new server route): resolve the opaque token via the session'splanIdMap; unresolvable / expired → redirect/plans. Old/plans/[planId]?<params>kept for backward-compat.
Ships flag-OFF (SESSION_FLOW_ENABLED = "disabled" in infra/envs/prod/ecs.tf) so the legacy query-param flow is byte-identical. Flips after the full CLAUDE.md member smoke passes against the session flow on prod — a watched cutover. Rollback: flip the value back to "disabled" + task-def rollover (~2-3 min, no code revert), exactly the ENG-321/323 enforcement-flag lever.
The client computes; the server persists
fetchPlansForHousehold (src/lib/fetch-plans.ts) issues relative/api/eligibility + /api/plans fetches that only resolve in a browser, and it is the audit-locked single source of pricing truth ("No other component should call /api/eligibility + /api/plans"). So the session routes never recompute pricing. The client runs the pipeline exactly as today and POSTs the computed result to /api/session/init for persistence. This keeps the pipeline DRY and the baseline-diff gate intact by construction. A scraper posting fabricated plan data only poisons their own session (per-session opaque IDs, their own cookie) — no cross-user impact; the anti-scrape value is the clean URLs + opaque IDs + rate limit, not server recompute.
Cookies
af_session HttpOnly + Secure + SameSite=Strict + path=/ + 7-day max-age
Backend session-state pointer. Replaced in place on a
calculator re-submit (same id, fresh plans).
af_visitor_id HttpOnly + Secure + SameSite=Lax + path=/ + 1-year max-age
Long-lived human identity. Never reset across calculator
re-submits. First-party analytics event-identity
continuity (OpenPanel, ADR 0009 / ENG-347).Both are HttpOnly — no client JS reads them; the session is resolved server-side on every render/route.
Mobile transport (ready day 1)
resolveSessionId(request) reads, in order:
Authorization: Bearer af_sess_<hex>(iOS Keychain / Android Keystore)af_sessioncookie (web)
One backend lookup path serves both transports. When native apps land, the session API is reused unchanged.
Opaque plan IDs
opaqueId = "p_" + HMAC_SHA256(message = plan_id, key = sessionId)[:10 hex]Keyed by the session's own high-entropy random _id (server-only) — no external secret, and the same plan is a different token in every session, so /plans/p/<opaqueId> URLs do not replay across users. buildPlanIdMap resolves the ~1e-8 40-bit intra-session collision deterministically (salted re-hash) so the map stays 1:1. The full planIdMap lives on the session doc; GET /api/session never returns it (resolution is server-only).
Session document
marketing_sessions, _id = "af_sess_<hex>":
visitorId, createdAt, updatedAt, expiresAt (TTL)
zip, countyFips, countyName, state, household{income,size,isMarried}, people[]
aptc, csr, isMedicaid
plans[] (stored opaquely — only .id is read), planIdMap{opaque->real}
selectedDrugs[], selectedDoctors[]
sourceChannelPlan objects are persisted opaquely on purpose: the PlanDisplay shape evolves every PUF release and this store must not become a second schema to keep in lockstep. The route layer bounds array sizes (DoS guard) but does not deep-validate plan internals.
Lifecycle
calculator submit ─ client runs fetchPlansForHousehold (browser) ─┐
v
POST /api/session/init ── create (or replace if a live session │
cookie is present) ── set cookies ────┘
v
/plans ── read session by cookie/Bearer, render plans[]
/plans/p/<opaqueId> ── resolve via session.planIdMap, render detail
add drug/provider ── POST /api/session/coverage (persist selection)
"Clear all" ── POST /api/session/coverage/clear
calculator re-submit ── POST /api/session/init replaces state in place
(same af_session, fresh plans + opaque map,
coverage cleared; af_visitor_id preserved)
session idle 7 days ── Mongo TTL reaps the docThe URL is the only source of truth for what is rendered; the session holds identity + plan list + opaque map. There is no "currently viewing" state to go stale, so browser back/forward and plan-to-plan navigation work natively (UX constraint #1).
Backward compatibility
Next.js forbids setting cookies during a Server Component render (only Server Actions / Route Handlers), so the original "silent mint + redirect to clean URL" cannot live in the /plans page. Shipped behavior instead: when the flag is on and an old ?param link is hit with no session cookie, /plans simply renders from the params exactly like the legacy path — old email/bookmark links keep working, they just don't get upgraded to a clean URL. New calculator flows mint the session via the /api/session/init route handler (which CAN set cookies) and navigate clean. The auto-upgrade of old links to clean URLs is a deferred follow-up; legacyContextToIdentity() remains available for it.
Rollback
- Phase 1: revert the PR. The collection + TTL index are inert (nothing writes to them in the default flow); leaving them is harmless. No infra/secrets/Terraform changed.
- Phase 2: set
SESSION_FLOW_ENABLEDback to unset/disabledon the prod ECS task definition — instant revert to the legacy query-param flow, no redeploy of code required.
Analytics (ADR 0009 / ENG-347 contract)
When the self-hosted first-party stack (OpenPanel + GlitchTip) lands: opaque per-session IDs mean $pathname analytics can no longer carry plan_id. Fire custom events server-side with resolved business data (plan_detail_viewed, calculator_submitted, coverage_filter_added, share_created, plus the sbe_redirect_shown unserved-demand signal) and key identity off af_visitor_id (stable across re-submits, the anchor of the visitor → member → device cross-surface graph), not af_session. Net: no income/aptc/csr in any URL to capture, richer semantic events, one continuous marketing→portal→app funnel. See ADR 0009 and ENG-347 / #342 for the full event taxonomy + phasing.