Appearance
Consent Capture & Versioning
Status: Active. Effective 2026-05-11. Owner: Taha Abbasi (technical implementation) + Asad Khalid (legal / regulatory). Reviewed: Whenever a new email-capture surface ships, or when the privacy policy / terms / consent statements change. Linear: ENG-201 · GitHub: #58
Purpose
Every email AskFlorence captures must carry provable evidence of consent: which version of the privacy policy + terms + consent statement the user agreed to, plus IP, userAgent, timestamp, page URL, and opt-in flags. This document is the operating manual for that system.
Required by:
- GDPR Art. 4(11) / Art. 7 — consent must be a "freely given, specific, informed and unambiguous indication" and the controller must be able to demonstrate consent was given.
- CCPA / CPRA — disclosure of personal information categories at collection.
- CAN-SPAM Act — proof of opt-in for commercial email; persistent unsubscribe.
- TCPA — "prior express written consent" for platform-purpose contact.
- HubSpot / Salesforce CRM import — both vendors block list imports without per-record statement text + capture metadata.
The non-negotiable property the system must support: given any consent record, we can answer "show me the exact text that this user agreed to on date X" by URL.
Schema
Every email-capturing record stores a consent sub-document with this shape, defined and built by src/lib/consent.ts:
ts
consent: {
versions: {
privacyPolicy: string; // e.g. "2026.04"
termsOfService: string; // e.g. "2026.04"
consentStatement: string; // verbatim text shown to the user
};
optIns: {
platformContact: boolean;
marketingEmail: boolean;
marketingSms?: boolean;
};
capturedAt: Date;
ip: string;
userAgent: string;
referrer?: string;
pageUrl: string;
method: "checkbox" | "submit_button" | "implicit" | "verbal_recorded";
}The versions.consentStatement field is the literal text shown to the user, not a key. Keys are stored separately (see Statement keys) for downstream filtering, but the text is the legally-meaningful artifact and is frozen into the record at capture time.
Version registry
The source of truth for which versions exist lives in src/lib/legal-versions.ts:
ts
export const PRIVACY_VERSION_HISTORY = [
{ version: "2026.04", effectiveDate: "April 17, 2026" },
] as const;
export const TERMS_VERSION_HISTORY = [
{ version: "2026.04", effectiveDate: "April 17, 2026" },
] as const;The last entry in each array is "current." LEGAL_VERSIONS.privacyPolicy and LEGAL_VERSIONS.termsOfService are derived from those tails — a single edit bumps the registry without two-place drift.
Helpers exported from the same module:
| Helper | Use case |
|---|---|
getCurrentLegalVersions() | Return { privacyPolicy, termsOfService } for the current versions |
getLegalVersionMeta(doc, version) | Resolve { version, effectiveDate } for a known version, or null |
isKnownLegalVersion(doc, version) | Boolean check |
getConsentStatement(key) | Type-safe accessor for CONSENT_STATEMENTS[key] |
Frozen archive
Old versions stay live at /privacy?v=YYYY.MM and /terms?v=YYYY.MM. The page components are thin shells that:
- Read
searchParams.v(Next.js 16 async-params pattern). - If
vis absent → render the current version. - If
vmatches a known version → render the frozenBodyfromsrc/app/privacy/_versions/vYYYY-MM.tsx(or the terms equivalent) and set<meta name="robots" content="noindex,follow">. Canonical always points at the un-suffixed URL (/privacyor/terms) so search engines don't see the archive as duplicate content. - If
vis unknown →notFound()(404).
| URL | Renders | Robots |
|---|---|---|
/privacy | Current version | index,follow (default) |
/privacy?v=2026.04 | Frozen v2026.04 prose | noindex,follow |
/privacy?v=2099.99 | 404 | — |
Frozen version modules (src/app/privacy/_versions/v2026-04.tsx etc.) must never be edited once shipped. They are append-only artifacts. Editing one breaks the "show me what the user agreed to" property for every consent record that points at that version.
Capture surfaces
Every API route that captures an email writes a consent sub-document. Grep for captureConsent( to enumerate.
| Surface | API route | Collection | statementKey | method |
|---|---|---|---|---|
| Consumer waitlist (home, agents marketing) | /api/waitlist | agent_waitlist_submissions | agentWaitlist | submit_button |
Plan-interest signup (on /plans/[planId]) | /api/waitlist | agent_waitlist_submissions | planInterest | submit_button |
| Agent discovery survey (partial save) | /api/agents/discovery | agent_survey_responses | agentSurvey | submit_button |
| Agent discovery survey (full submit) | /api/agents/discovery | agent_survey_responses | agentSurvey | checkbox |
Whenever you add a new email-capture surface:
- Pick or add the appropriate key in
CONSENT_STATEMENTSinsrc/lib/legal-versions.ts. - Call
captureConsent({ req, statementKey, pageUrl, optIns, method })and store the returned object as theconsentfield on the inserted/upserted document. - Add a row to the table above in this doc.
Statement keys
The keys in CONSENT_STATEMENTS are stable identifiers that decouple the purpose of the statement from its text. When prose changes between versions, the key stays the same — the text inside changes. Consent records freeze the text (not the key), so reverse-lookup from the stored text identifies which statement the user agreed to.
| Key | Surface |
|---|---|
agentWaitlist | Agent-side waitlist sign-up flows (home, /agents, /agent-onboarding) |
agentSurvey | /agent-discovery survey (partial + full submission paths) |
planInterest | Plan-interest follow-up on /plans/[planId] |
Export workflow (for CRM imports)
When HubSpot or Salesforce demands a per-record proof-of-consent list, run:
bash
# All consent records, all-time
npx tsx scripts/export/consent-for-crm.ts --collection all > /tmp/consent.csv
# Just waitlist, last 30 days
npx tsx scripts/export/consent-for-crm.ts --collection waitlist --from 2026-04-12 --out /tmp/waitlist-recent.csv
# Just discovery survey, capped at 100 rows
npx tsx scripts/export/consent-for-crm.ts --collection survey --limit 100The script (scripts/export/consent-for-crm.ts) is read-only — it uses MONGODB_URI (the app_read user), never the write URI. Safe to run against production.
CSV columns:
email, full_name, company_name, consent_captured_at, consent_ip, consent_user_agent,
privacy_policy_version, terms_of_service_version, consent_statement, statement_key,
opt_in_platform_contact, opt_in_marketing_email, opt_in_marketing_sms,
source_page, page_url, referrer, method, source_collection, submission_idconsent_captured_atis ISO-8601 UTC.consent_statementis the verbatim text the user saw. This is the field HubSpot/Salesforce want to see.statement_keyis the reverse-lookup of the text againstCONSENT_STATEMENTS. Empty if the text doesn't match a known key (would only happen for legacy records predating the registry — none today).
Retention
Consent records inherit retention from the parent row + an additional rule:
agent_waitlist_submissionsrows: 6 years from last activity (data retention policy).agent_survey_responsesrows: 6 years from collection.- The
consentsub-document inside each row: 10 years minimum (HIPAA-aligned, EDE-safer) — the sub-document MUST NOT be dropped or redacted before the row itself ages out, even if the user requests deletion of their account. Right-to-erasure requests delete the row outright; partial redaction would break the audit trail.
Cross-link: Data Retention Policy.
Rolling a new version
When the privacy policy or terms text needs to change:
- Decide the new version string. Use
YYYY.MM(e.g.2026.06). Bumps are content-driven, not calendar-driven — only roll if the prose actually changes. - Create the frozen module. Copy
src/app/privacy/_versions/v2026-04.tsxtosrc/app/privacy/_versions/v2026-06.tsx(or whatever the new slug is). Edit the prose in the new file only. UpdateversionandeffectiveDateexports. - Append to the registry in
src/lib/legal-versions.ts:tsexport const PRIVACY_VERSION_HISTORY = [ { version: "2026.04", effectiveDate: "April 17, 2026" }, { version: "2026.06", effectiveDate: "June 1, 2026" }, // ← new tail ] as const; - Wire the new module in
src/app/privacy/page.tsx: import it, add an entry to theVERSIONSarray. - If the consent statement text changed, update the corresponding key in
CONSENT_STATEMENTS. Old consent records still carry the prior text verbatim — that's the point. - Verify both URLs:
/privacyrenders the new version/privacy?v=2026.04still renders the old version (now marked "Archived view")/privacy?v=<new-version>renders identical content to/privacy
- Update this doc's capture-surfaces table if any new surface was added.
- Material change? Email all consenting users before the new version takes effect (handled separately by ops).
Verification
To verify the system end-to-end:
bash
# 1. Type-check
npx tsc --noEmit
# 2. Docs build
cd docs && npm run build
# 3. Local dev server — browser checks
npm run dev
# - GET /privacy → current version, robots default
# - GET /privacy?v=2026.04 → same prose, robots: noindex,follow
# - GET /privacy?v=2026.99 → 404
# - GET /terms → analogous
# - View-source: <link rel="canonical" href="/privacy">
# 4. Export script (with MONGODB_URI in env)
npx tsx scripts/export/consent-for-crm.ts --collection all --limit 3
# stdout: CSV header + up to 3 rows; stderr: row count summaryRelated
- Privacy Impact Assessment — data-flow-level analysis of every email-capture surface
- Data Retention Policy — retention period for
agent_waitlist_submissions,agent_survey_responses, and consent records - Agent Platform Compliance — original design intent for the consent capture system