# Visitor Continuity — Plan

**Status:** APPROVED — build in progress. Targets **1.7.0**.
**Written:** 2026-08-06, after an adversarial design review (five independent expert
lenses: browser persistence, WordPress/CDN delivery, cost/YAGNI, privacy/security,
identity resolution).

Vendor-neutral by rule: destination domains, CRMs and portals are configuration. No
client or vendor name belongs in this document or in the code.

---

## 1. The problem

A journey is recorded in the visitor's own browser. Today the handoff token is
`sessionStorage`-scoped, so a returning visitor is anonymous to the server again on
their next session.

The scenario this exists for: **someone browses for months, converts, then keeps
browsing for months more, on a multi-year cycle.** Their later browsing should attach
to the journey we already saved. Also common on the same site: browse on a phone,
convert on a laptop; families sharing one device.

## 2. Corrections to earlier assumptions

These were believed during design and are wrong or optimistic. They are the reason
this document exists.

1. **No cookie is durable for years.** Every major browser caps cookie lifetime to
   **~400 days** — server-set or script-set alike. The visitor key is a **rolling
   ~13-month window that must be re-issued on every append**, not a set-once token.
   Past a genuine multi-year silent gap, no cookie survives.
2. **"Server-set escapes Safari" is true but narrow.** It escapes the ~7-day
   script-writable-storage purge and the ~24h cap applied to script-set cookies on
   link-decorated arrivals (`gclid`/`fbclid` — i.e. paid traffic). It does **not**
   exempt anything from consent law: storing on the device triggers ePrivacy
   regardless of who set it. **Reliability, not a legal exemption.**
3. **`localStorage` is redundancy, not durability.** Safari and Brave purge it on the
   same ~7-day clock. It carries Chrome/Firefox continuity only. The existing
   `document.cookie` fallback claims a 365-day expiry; on that audience it is
   effectively 7 days, or ~24h for link-decorated arrivals.
4. **This is not a Safari-only problem.** Brave applies the same caps. Together
   roughly 40–45% of a consumer audience.
5. **"One durable row per visitor" cannot represent either real journey.**
   Cross-device is many devices → one person. Shared device is one device → many
   people. **An anchor is a device, not a person.**
6. **A bare `sha256(email)` is effectively the email** — low entropy, enumerable.
   Use `HMAC-SHA256(normalised_email, site_pepper)`.
7. **`HttpOnly` is half a privacy win.** It genuinely stops JS, XSS and third-party
   tags reading the *key*. It does nothing about the server-side record the key points
   at. Do not let it soften governance, and do not sell it to clients as a privacy
   feature.

## 3. Persistence stack

In priority order:

| Layer | Role | Survives |
|---|---|---|
| **Server-set cookie `bwlai_vk`** | Primary durable key | Safari + Brave (the only layer that does), ~400d rolling |
| **`localStorage` journey mirror** | Redundancy | Chrome / Firefox continuity |
| **`email_hmac` stitching** | The bridge | Cross-device, cleared cookies, gaps > ~13 months |

Cookie specifics: 128-bit CSPRNG, `HttpOnly; Secure; SameSite=Lax; Path=/`,
`Max-Age ≈ 400d`, **re-issued on every append** so the window slides forward, set
**only on the uncached POST REST path** with `Cache-Control: private, no-store`.
Name it `bwlai_vk` — never `wp-*`/`wordpress_*`, which trip cache-bypass rules.

## 4. The trigger — the piece the original design was missing

**Quiet return browsing never calls the server.** It hits cached pages, PHP may not
even run, nothing appends, and the cookie never re-stamps. The original design set the
cookie "when we mint or update a record", but a visitor browsing without converting
mints nothing — so the feature would have demoed perfectly on the submission path and
recorded nothing for the case it was built for.

**A throttled return-visit beacon fixes all three problems at once**: a first-party
POST from `capture.js`, **once per session** (flagged in `sessionStorage`), carrying
the cookie and the journey delta. The server appends and re-stamps the cookie. It is
uncached by construction, so it also sidesteps the cache-strip risk.

This is not optional. It is the trigger that makes continuity function.

## 5. Data model — a device is not a person

**`anchors`** — evolves the existing handoff table. *An anchor is a device.*

| Column | Purpose |
|---|---|
| `visitor_key_hash` UNIQUE | The server-set cookie, hashed at rest |
| journey events | **Append-only**, each with an `event_id` + timestamp, so claim/confirm/beacon are idempotent and order-independent |
| `token_hash` | The existing short-TTL handoff bearer — unchanged |
| `lead_id` nullable FK | The person this device resolved to, if known |
| `shared_flag` bool | Set when one device produced two identities |
| `link_provenance` | `key` \| `email_hmac` \| `manual` |
| `expires_at`, `status` | Held / saved lifecycle, as now |

**`leads`** — new. *A lead is a person.*

| Column | Purpose |
|---|---|
| `id` | |
| identity | name / email / phone, access-controlled |
| `email_hmac` INDEXED | The stitching key |
| `lead_status` | For the reporting work already signalled |

Cross-device falls out for free: two anchors sharing one `lead_id` is many-to-one.

**Stop there.** No many-to-many table, no merge-history graph, no probabilistic
matching. Those are the over-built versions.

## 6. Resolution rules

1. **Append path** (beacon, claim, on-site form submit): visitor key → append events to
   its anchor, re-stamp the cookie. **No identity logic here at all.**
2. **On submission** (identity arrives): compute `email_hmac`; match an existing lead
   or create one. Set `anchor.lead_id` **only if** the anchor is unclaimed, or already
   points at that same lead.
3. **Shared-device guard:** if the submitted email resolves to a *different* lead than
   `anchor.lead_id`, set `shared_flag = true`, **stop auto-attributing that device's
   future browsing to any single person**, fall back to per-submission snapshots, and
   record the pair as a reconciliation candidate.
4. Post-conversion browsing attaches to a lead **only while `shared_flag` is false.**

**Link, never merge. Never rewrite past ownership.** The failure this prevents — one
person's browsing attributed to another person's lead record on a site whose audience
includes minors — is silent, destructive on write, and unrecoverable afterwards. That
is why the split is build-now rather than deferred.

## 7. Build now vs defer

**Build now** (write-time, unrecoverable-if-wrong):
- Server-set cookie, re-stamped on every append
- The return-visit beacon
- anchors/leads split + `shared_flag` guard
- `email_hmac` stitching — exact match only, link never merge
- Finite default retention + inline pruning
- The guardrails in §8

**Defer behind a clean seam:**

| Deferred | Seam | Why |
|---|---|---|
| Pre-conversion anonymous anchor (server row before any action) | An independent early REST call behind the same off-by-default setting | A DB row per anonymous visitor plus bot noise, for marginal gain. Set expectations instead: pre-conversion depth is solid on Chrome, partial on Safari/Brave |
| Shared-device reconciliation **UI** | `shared_flag` + `link_provenance` ship now; the admin screen that acts on them ships later | Zero rework |
| Retention granularity beyond on/off + duration | A settings read | Add when a client actually asks |

## 8. Guardrails that ship with the feature

1. **Cache safety on `Set-Cookie`** — `Cache-Control: private, no-store`; cookie set
   only on POST, never a cacheable GET; custom cookie name; exclude the REST namespace
   from any "Cache Everything" rule. **Plus a deploy check that hits the endpoint twice
   and asserts two different keys.** If a `Set-Cookie` is ever cached, every visitor
   receives the *same* key and their named histories merge into one record — silent,
   breach-grade, unrecoverable.
2. **Append endpoint proven read-nothing** — bare 200/204, zero journey or identity
   data in the body, asserted by a contract test. Otherwise the append key quietly
   becomes a read token.
3. **Rate limit and size-cap appends**, per key and per IP.
4. **Export and erasure by email** via WordPress's `wp_privacy_personal_data_*` hooks —
   non-negotiable once named histories are keyed by email, and the plumbing exists.
5. **Consent seam** — a documented `bw_lead_ai_capture_allowed` filter a site's CMP can
   wire to. The seam only; this plugin is not a consent platform.
6. **Finite default retention** (12–24 months rolling) and **inline pruning** —
   opportunistic `DELETE … WHERE expires_at <= now LIMIT n` on mint/append, because
   wp-cron is unreliable on this fleet. "Unlimited" stays available but is not a
   default.
7. **`HMAC-SHA256` with a site pepper** stored in `wp-config.php` or an option — a
   per-row salt would break lookup.

## 9. Settings changes

- Retention: **finite default**, "unlimited" available but not the default.
- **Drop** the three-way update-frequency granularity (`never` / `on resubmit` /
  `on every visit`). Ship **on/off plus a duration**. Add granularity only on request.

## 10. Top risks

1. **The append feature having no trigger.** Ship the cookie and the model without the
   beacon and it works on the submission path, rolls out, and silently records nothing
   for months-of-browsing — the exact case it exists for.
2. **A cached `Set-Cookie` merging every visitor into one record.** Silent,
   breach-grade, unrecoverable. §8.1 exists for this.
3. *(Runner-up)* **The shared-device wrong-merge.** Prevented only by the anchor ≠
   person split and the `shared_flag` guard — the one thing here that cannot be fixed
   retroactively.
