Appearance
Privacy, Consent & Retention
The governance layer for NanaSelect's behavioral data (issue #347, part of capability #346). It establishes the lawful basis, the consent gate, retention windows, and the right-to-delete — the rules that sit around the anonymous session store and the Eloqua integration, before and alongside instrumentation.
The one-line version: NanaSelect captures nothing about an identifiable person, and retains no anonymous analytics, without an affirmative Iubenda consent signal read server-side at the edge — and it fails closed whenever that signal is absent, unreadable, or unconfigured.
Related: governance of media bytes sent to a vision model (a different data class — brand/product content, not visitor PII) is covered in Media Egress Governance & Posture (#1190).
Consent source of truth — Iubenda via GTM
Consent is owned by Iubenda, running through Google Tag Manager on nanawall.com. NanaSelect shows no cookie banner of its own: the Worker reads Iubenda's first-party _iub_cs-<siteId> consent cookie server-side (src/lib/server/consent.ts) and gates capture on it. This mirrors the existing ELOQUA cookie recognition path (#106) — the consent module is handed the cookie value, never the request host, so it is host-agnostic and unit-testable.
Deployment precondition (AC-02)
The consent cookie is domain-scoped to .nanawall.com. It is therefore only edge-readable when NanaSelect is served under a nanawall.com subdomain (e.g. select.nanawall.com), not a standalone *.workers.dev origin. This is the same condition Eloqua cross-session recognition already requires (the ELOQUA cookie is likewise .nanawall.com-scoped) — one topology constraint, two needs. Until that subdomain is provisioned and the Iubenda cookie scope is set, the gate reads no cookie and every tier fails closed (no capture).
Two-tier consent model
The two consent tiers map onto the two data tiers already in the store:
| Tier | Consent purpose | What it gates | Data |
|---|---|---|---|
| Tier 1 — anonymous | Iubenda measurement | The selection_sessions snapshot write (PUT /api/session) | KB ids only, no PII (#114/#348) |
| Tier 2 — identifiable | Iubenda marketing and the explicit lead-form submit | Lead→Eloqua sync (POST /api/lead) and cross-session cookie recognition (#106) | email/name → Eloqua; opaque contactId in D1 |
- Tier 1 was previously written unconditionally; it is now retained only with measurement consent. Without it, the
ns_sessioncookie (a strictly-necessary functional identity minted by the hook) may still exist, but no analytics snapshot is written. - Tier 2 requires marketing consent and the explicit form submit — neither alone is sufficient. Without marketing consent a submitted lead is neither synced to Eloqua nor queued: the contact detail is not processed at all (fail closed). The HTTP response is still
ok— the visitor's outcome is decided by validation alone.
Purpose → operation mapping (config, never hardcoded)
The Iubenda purpose ids for measurement vs marketing are site-specific numbering and live in configuration (wrangler.jsonc vars), resolved by consentConfigFromEnv:
| Var | Meaning |
|---|---|
IUBENDA_SITE_ID | The nanawall.com Iubenda site id — names the _iub_cs-<siteId> cookie |
IUBENDA_PURPOSE_MEASUREMENT | Purpose id that grants Tier 1 (measurement) |
IUBENDA_PURPOSE_MARKETING | Purpose id that grants Tier 2 (marketing) |
While any of these is blank the gate is unconfigured and fails closed — an incompletely configured gate can never fall through to "granted". Confirming the real site-specific ids is a residual verification item (see Open items). A dev-only override (CONSENT_DEV_GRANT_ALL, keyed on dev at the call sites) keeps the local flow, seed data, and tests working without a real cookie; the production Worker never sets it.
Buyer geolocation (#698)
Coarse buyer location — city/region strings only, never precise coordinates — may be recorded on the Tier-1 session snapshot to power "projects near you" in guided selection. It is derived at the edge from the Cloudflare request geo (request.cf), with no third-party geo-IP service, and only when two independent gates are both open:
| Gate | Owner | Where |
|---|---|---|
Admin toggle (geo.collection_enabled, default OFF) | NanaWall admins | /admin/privacy → Buyer geolocation (audited) |
| Measurement consent (the Tier-1 purpose the snapshot itself rides on) | The visitor, via Iubenda | read fail-closed by consent.ts per request |
Either gate closed — or no city resolved at the edge — means no location is derived, stored, or used, and ranking falls back to the pure system-driven order (lib/server/geo.ts deriveBuyerGeo, unit-tested). The stored geo_city/geo_region columns live on selection_sessions, so the retention windows and right-to-delete paths cover them with no new machinery. The ranking read (GET /api/recommendation) re-derives location from its own request under the same two gates and persists nothing.
Consent change & withdrawal
Consent is re-read on every request, so withdrawal takes effect on the next call: drop measurement → Tier 1 capture stops; drop marketing → Tier 2 stops. Withdrawal is not retroactive — data already captured remains until the retention purge or an explicit right-to-delete removes it.
Retention windows
| Data | Window | Mechanism |
|---|---|---|
Anonymous snapshot (selection_sessions) | SESSION_TTL_DAYS (default 90) from last activity | Invisible to reads past TTL; daily cron purge (#116), cascading to attached integration rows |
Lead-sync queue (eloqua_sync_queue) | PII scrubbed on successful sync; terminal rows dropped after SYNC_QUEUE_RETENTION_DAYS (default 30) | scrubQueuePii + purgeSyncQueue on the same daily cron |
| Identifiable marketing record | Eloqua is the system of record | Out of scope for NanaSelect (see DSAR boundary) |
Identifiable data in NanaSelect is deliberately transient: a successful lead sync scrubs the queued email/name immediately (only PII-free throughput signal remains), and terminal (synced/abandoned) queue rows are dropped after the bounded window. No long-lived PII lives in NanaSelect.
Share links (#839)
"Copy link to these results" produces an answer-serialized permalink, not a stored token: the URL encodes only knowledge-base ids (the answers) plus the date it was generated. No PII can enter the link by construction, nothing is written server-side when one is created, and so there is nothing to expire or revoke — right-to-delete erases the sharer's session but neither can nor needs to invalidate links already copied, because a link never references that session. A recipient who opens one gets a live recomputation under their own session and consent state. Full treatment: session-store.md.
Right-to-delete (AC-07)
/admin/privacy erases everything NanaSelect holds for a visitor, by either key:
- By session id — deletes the queued-lead PII first (its FK is
SET NULL, so a session delete alone would orphan it), then theselection_sessionsrow, whoseCASCADEremoves theeloqua_session_contactsassociation. - By email — clears
eloqua_sync_queue, the only D1 table holding email/name PII.
Both actions are edit_data-gated and audited. The erased email is masked in the audit trail (maskEmail) so the erasure record never re-stores the address in plaintext.
DSAR boundary
NanaSelect stores only an opaque Eloqua contactId, never the marketing record itself. Erasing the visitor's Eloqua contact defers to nanawall.com's Eloqua DSAR process — this action erases everything NanaSelect holds and no more. That boundary is the deliberate division of responsibility between the standalone app and the marketing platform of record.
Open items
The following require human/infra confirmation and are tracked as TODOs on #347:
- Iubenda purpose ids — confirm the nanawall.com measurement vs marketing purpose ids and set
IUBENDA_PURPOSE_MEASUREMENT/IUBENDA_PURPOSE_MARKETING(never hardcode). - Consent Mode v2 / IAB TCF — confirm whether nanawall.com Iubenda drives Google Consent Mode v2 / the IAB TCF string; mirror those signals if so.
- Subdomain + cookie scope — provision the
nanawall.comsubdomain (Cloudflare custom domain) and set the Iubenda cookie domain scope to.nanawall.com. - Legal/DPO sign-off — on the measurement classification and the DSAR boundary wording.