Skip to content
AskFlorence
Main Navigation ArchitectureFlorence AIAgentsMembersAgent PlatformValidationInfrastructure

Appearance

Sidebar Navigation

Overview

Home

Glossary

System Architecture

Consumer & Agent Flow

Florence AI

Overview

Principles

Runtime

Tool surface

Adding a tool

Tool registry

Knowledge: SBC scenarios & CSR

Voice

Evals & observability

Provider risk & portability

Outage playbook

Roadmap

Build plan

Agents

Overview

Workflows & pain points

Members

Overview

Medicaid coverage gap

Carriers

Overview

Marketplaces

Overview

Agency

Overview

Regulations

Overview

Agent Platform

Overview

Auth Architecture

MongoDB Permissioning

Compliance Model

Data Models

Data Sources

Overview

CMS Marketplace API

CMS dependency map

PUF Data

State Subsidies

SBE Ingestion Playbook

SBE State Watchouts + Decisions

CA Phase C/D Playbook

NY Phase C/D Playbook

Validation

Overview

Methodology

APTC Formula

California 2026

New York 2026

CAPS Formula

Scenario Results

Infrastructure

Account Inventory

AWS Setup Runbook

AWS Organizations

CloudTrail

GuardDuty

Security Hub

Config

CloudFront + WAFv2

Data sources & ingest

Phase 4 DNS

Change Log

Vulnerability Management

MongoDB Setup

Access Control

Data Classification

Documentation Hosting

Post-deploy Smoke

Development

Preflight (local CI mirror)

Testing strategy

Compliance

Overview (auditor entry point)

SOC 2 Control Mapping

HIPAA Control Mapping

CMS EDE Appendix A Mapping

Risk Assessment

Encryption Policy

Data Retention Policy

Privacy Impact Assessment

Consent Capture & Versioning

Incident Response Plan

Access Control Policy

Marketing vs. Portal Analytics

Vendor / Subprocessor Register

Dependency Vulnerability Policy

BAA / Compliance Evidence

Compliance-Automation Integration

Compliance-Automation Vendor Evaluation

Penetration Test Reports

Architecture

Portal entry handoff

Mobile app strategy

Deferred architecture decisions

Session cookie architecture

Share flows

Decisions (ADRs)

Index

0001 — Atlas project isolation

0002 — Append-only audit log

0003 — Narrow-scoped Mongo users

0004 — Cross-cluster Atlas PrivateLink

0005 — Delayed-job architecture

0006 — Mongo user simplification

0007 — Terraform owns ECS task def

0008 — E2E testing strategy

0009 — Self-hosted analytics + observability (superseded)

0010 — PostHog HIPAA Cloud (supersedes 0009)

Runbooks

Security Incident Response

Break-Glass Root Login

Onboard Team Member

Offboard Team Member

Atlas user provisioning

Deploy via Terraform (ENG-277)

Rollback via Terraform (ENG-277)

S3 data bucket migration (planned Phase 11)

Access Reviews

2026-Q2 Review

Session log

Index

2026-04-23 — Phase 10 DNS cutover

2026-04-22 — Phase 8 prod AWS mirror

2026-04-22 — Phase 7 Atlas VPC peering

2026-04-22 — Phase 6 CloudFront + WAF

2026-04-21 — Phase 5 staging go-live

2026-04-17 — Atlas staging

Briefs

Index

Member portal plan (ENG-187)

2026-04-16/17 handoff

2026-04-17 Atlas handoff

System briefing (2026-04-17)

Creative AdBundance proposal brief

Creative AdBundance analytics brief

ElevenLabs RN integration research

Policies

Overview

On this page

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=2

Two problems:

  1. State leakage — income / aptc / csr ride Referer headers, browser-history sync, CloudFront + ALB access logs, and analytics URL capture.
  2. 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_sessions Mongo collection + TTL index (expiresAt, expireAfterSeconds: 0, +7 days) via the existing deploy-time ensure-indexes ECS RunTask — no new infra, no new secrets (app_write already has DB-wide readWrite per 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-safe SESSION_FLOW_ENABLED (no node:crypto), the single source of truth; marketing-session.ts re-exports it. Read server-side at runtime (a NEXT_PUBLIC_ var would be frozen into the client bundle at next build and defeat the runtime flip).
  • The boolean is delivered to client components via src/lib/session-flag-context.tsx, never a client process.env read, via two providers on the same context:
    • Dynamic pages (/plans, /plans/p/* — they call cookies()): <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 at next build): <SessionFlagAutoProvider> fetches /api/session/flag (a force-dynamic route 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.)
  • Calculator (useCalculator({ sessionFlowEnabled })): on pipeline success, POST /api/session/init and, 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's planIdMap; 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:

  1. Authorization: Bearer af_sess_<hex> (iOS Keychain / Android Keystore)
  2. af_session cookie (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[]
sourceChannel

Plan 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 doc

The 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_ENABLED back to unset/disabled on 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.

Pager
Previous pageDeferred architecture decisions
Next pageShare flows

AskFlorence Internal Documentation. Not for public distribution.

AskFlorence

Internal Documentation

Access restricted. Not for public distribution.