# Changelog

## [1.14.2] - 2026-09-03

### Changed

- Package metadata: `homepage` now points at this package's own documentation page rather
  than the marketing homepage, `repository.url` normalised to the `git+https` form npm
  expects, and an explicit `bugs` URL added. npmjs.com renders these as links, so they are
  how a reader gets from the package page to the docs.
- Added `SECURITY.md` so a vulnerability has a private reporting route
  (security@cross-deck.com) instead of a public issue.

No runtime or API changes.

All notable changes to `@cross-deck/web` will be documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.14.1] — 2026-08-20

**`consentBanner` is OPT-OUT by default. 1.14.0 shipped it consent-first, which was wrong for the full SDK.** Switching the banner on no longer denies analytics: the SDK keeps collecting exactly as it does today and the visitor may decline. 1.14.0 silently zeroed analytics for anyone who enabled the banner — an EU posture imposed on every install, including the majority of the world where opt-out is lawful. Crossdeck does not decide a customer's legal posture on their own site; we give them the switch, not the opinion.

- **Opt-in is now an explicit choice:** `consentBanner: { policyUrl, denyUntilChoice: true }` restores consent-first for operators who need it (EU/EEA — GDPR + ePrivacy).
- **The widget opens showing the truth.** It is seeded from the live consent state, so an opt-out banner renders with analytics already on and an opt-in one does not.
- **Unchanged:** enforcement stays core (the `consent()` socket, GPC honoured by default), and the GUEST build `@cross-deck/web-lite` / `startConsentMode()` stays **consent-first** — on someone else's site the visitor never chose us, so prior consent is the only defensible default. That split is the point of having two builds.

## [1.14.0] — 2026-08-06

**Consent Mode — the privacy-restricted guest build (Path B) lands in the SDK.** A new surface for marketplace / guest installs (Webflow, Wix, Squarespace, …) where Crossdeck is a guest on someone else's site and must behave like one. `startConsentMode()` boots a consent-first posture and mounts the branded Crossdeck Consent widget. **Additive — the full SDK's defaults are unchanged**; this is a separate boot profile, not a change to `init()`/`track()`/`identify()`.

- **New API:** `startConsentMode(opts)` (the single on-switch), `mountConsentBanner(opts)`, `detectExistingConsent()` + `subscribeToExternalConsent()` — the widget defers to any existing consent tool on the page (GPC / IAB TCF v2.2 / GPP / Google Consent Mode / Cookiebot / OneTrust / platform-native) and never renders a second banner.
- **Guest posture:** autocapture off until consent, marketing off by default, identity is **explicit opt-in only** (new `setIdentityOptIn()`; `identify()` no-ops until granted — default `true`, so the core path is unchanged), host-scoped identity storage.
- **New `autoTrack` options (additive; defaults preserve current behaviour):** `stripUrlParams` (drop query strings, fragments and referrers from analytics) and `wrapHistory` (set `false` to capture only the initial page view and never wrap `history.pushState`/`replaceState`).
- **Consent is OPT-IN, not bundled into every install.** Enforcement and presentation are separated. **Enforcement is core and always on**: the `ConsentManager`, the public `Crossdeck.consent()` API — the socket any external CMP (OneTrust, Cookiebot, IAB TCF…) plugs into — and **GPC (`navigator.globalPrivacyControl`) honoured by default, with no flag**. Nobody has to opt in to "a visitor's banner can tell us to stop". **Presentation is a feature you switch on**: the branded widget, the CMP auto-detect adapters and the guest boot profile now load only when asked, via `consentBanner` in `init()` or the new `@cross-deck/web/consent` subpath export. Most sites already run their own CMP (which is exactly why the coexistence adapters exist), so shipping the widget to everyone taxed every install for something most decline.
- **The light switch.** `Crossdeck.init({ …, consentBanner: "https://yoursite.com/privacy" })` mounts the Crossdeck banner — no second vendor, no extra script, no config portal. A privacy-policy URL *is* the switch, because a banner that links to no policy is refused (managed mode). `consentBanner: true` is the right form when you expect an existing CMP: we adopt its answer and render nothing. Turning it on is **consent-first** — analytics and marketing stay denied until the visitor chooses.
- **Packaging:** the widget is code-split, so a customer who never switches it on downloads none of it. Script-tag users add the companion `crossdeck-consent.umd.min.js` (`window.CrossdeckConsent`); the core UMD no longer carries the widget. `@cross-deck/web-lite` is unchanged for guests — it still bundles the widget in, because on someone else's site the banner is not optional.
- **Bundle-size budgets re-baselined, and the gate made honest.** The size check now measures each entry **plus every chunk it imports statically** — code splitting had turned an entry into a thin shell, so the old entry-only measure would have reported ~7 KB for a 67 KB download. Against that stricter measure the widget leaving core takes ESM from 74.6 → **67.4 KB** and the UMD from 44.0 → **37.3 KB**; core gains ~1 KB for the new enforcement (GPC, identity opt-in, URL-param stripping, history-wrap control). Budgets sit just above measured truth so future creep still trips.
- **The consent-mode variant also ships standalone as `@cross-deck/web-lite`** — the clean-cut package for marketplace connectors, pinned + integrity-hashed for hosted delivery.

## [1.13.5] — 2026-07-28

**The real fix: `@cross-deck/web` and `@cross-deck/web/react` now share ONE SDK instance.** This is the definitive cause of a paying customer seeing "Upgrade to PRO" while the network showed their entitlement resolving correctly — and why the 1.13.2 / 1.13.3 cache fixes didn't clear it (they were correct, but operating on the wrong object).

- **The defect:** the two entry points build as separate bundles, and each **inlined its own copy** of the module that does `export const Crossdeck = new CrossdeckClient()`. So the package shipped **two singletons**: the one from `@cross-deck/web` (what you `init()` / `identify()` / `getEntitlements()` on) and a *different* one baked into `@cross-deck/web/react` (what `useEntitlement()` reads). You warmed instance A; the hook read instance B, which was never initialised — `false`, forever, silently. Nothing in the API hinted at the split, and the docs show the core calls and the hooks used together.
- **The fix:** the singleton is now backed by the cross-realm **global symbol registry** (`Symbol.for("@cross-deck/web:Crossdeck")`), in a dedicated module every entry point imports. No matter how a bundler duplicates the code, all copies resolve to the **same** instance — the same guard PostHog, Segment, and LaunchDarkly use for their React bindings. `init()` on the core import and `useEntitlement()` in a component now read one object; the paywall clears with **zero app changes**.
- **Can't silently regress:** the module logs a dev-mode warning if it's ever evaluated more than once (duplicate packaging), and a CI test loads both built bundles and asserts they share one instance.
- Creating extra instances on purpose (`new CrossdeckClient()`) is unaffected.

## [1.13.3] — 2026-07-28

**A paying customer no longer sees the paywall flash to "locked" when your app re-identifies them — the deterministic root cause.** This is the defect behind a Pro customer still seeing an upgrade prompt even though the server correctly returns their entitlement. On a **same-user re-identify** — which every React/Vue app triggers, because auth traits arrive in stages (uid first, then email, then profile name) so `identify()` runs 2–3 times with the same id — the entitlement cache emitted an **empty snapshot** to every subscriber *before* restoring the real value. `useEntitlement()` / `useEntitlements()` / the Vue composable / any `onEntitlementsChange()` listener latched that empty emission, rendered the locked state, and was never told to re-read (the restore was silent).

- **The in-memory value was always correct** — which is why synchronous `isEntitled()` and your server-side gate were never wrong, and why the paid, money-costing endpoints stayed correctly protected the whole time. Only the **reactive UI** flashed the paywall.
- **The fix:** `setUserKey()` now restores the user's last-known-good **first**, then emits the settled snapshot exactly once. One code path for both the identity switch and the same-id re-identify — subscribers never observe a transient empty state. No app changes needed; the paywall clears on upgrade.
- **Also hardened — stale-fetch clobber (`getEntitlements`).** Because `identify()` and explicit callers keep ≥2 entitlement fetches in flight (plus the anonymous boot fetch), an early request could resolve *after* the customer identified and overwrite the authoritative answer with a stale one (last-writer-wins). Each fetch now carries an identity **generation** captured before the request; if the identity rotates while it's in flight, the stale write is dropped.

## [1.13.2] — 2026-07-28

**`identify()` now settles entitlements from the server — `isEntitled()` is correct the moment `await identify()` resolves.** Previously, `identify()` switched the caller to the new user's local entitlement slot but never asked the server what that user is entitled to. A user who identified after signing in read `isEntitled("pro") === false` until the app separately called `getEntitlements()` — the footgun behind a paying customer briefly seeing the free experience. Now, when the newly-identified user's slot has no cached last-known-good, `identify()` queries the server (the authority) and settles the answer before it resolves.

- **The identity switch decides off the server, not the anonymous cache.** The prior (anonymous) slot is still cleared on switch — a shared-device anon session never becomes the new user's entitlements (unchanged security property). The difference: instead of landing on an *empty* answer, the new user's entitlements come from the server, which resolved them via the same alias that linked their anonymous history.
- **A returning user with a warm slot skips the fetch** and reads their own last-known-good instantly — no added latency, no flash of free.
- **Best-effort, fail-open for payers.** If the refresh can't reach Crossdeck, `identify()` still succeeds (its job — the alias — already did) and last-known-good stands; a network blip never locks a paying customer out.
- **`getEntitlements()` no longer crashes on a malformed response.** A `200` whose body carries no entitlement array (schema drift, a proxy that rewrote the body) now throws a clean `CrossdeckError` (`malformed_entitlements_response`) and preserves the durable cache, instead of a `TypeError`. The cache is never clobbered with garbage.
- **Fixed a false-positive `per-user-cache-isolation` diagnostic.** The self-check flagged a ⚠️ when a returning user legitimately rehydrated their own entitlements. It now asserts the true invariant — the in-memory snapshot matches the *current* slot's storage (sourced from this user, never leaked from another) — instead of the too-strict "must be empty."

## [1.13.1] — 2026-07-27

**Crossdeck Trust panel — the publishable key is now an explicit prop (Stripe / Turnstile pattern).** `<CrossdeckTrust>`, `useTrustToken()`, and `Crossdeck.trust.panel()` read the key from `Crossdeck.init()`, but `@cross-deck/web` and `@cross-deck/web/react` build as separate bundles with separate singleton instances — so an `init()` on one didn't reach the component's copy, and the panel mounted with no key (blank). Now you can pass it explicitly and skip `init()` entirely:

```jsx
<CrossdeckTrust publicKey="cd_pub_live_…" onToken={(t) => setToken(t.token)} />
// or:  useTrustToken({ publicKey: "cd_pub_live_…" })  ·  Crossdeck.trust.panel({ publicKey, target })
```

Explicit `publicKey` always wins; omit it and the SDK still falls back to `init()`'s key. No hidden global state to get wrong.

## [1.13.0] — 2026-07-26

**Crossdeck Trust — the human-proof panel, native to the SDK.** Render the branded, un-restylable Trust panel (the same cross-origin `trust.cross-deck.com` iframe on every install) and mint a single-use attestation, handed back to you programmatically — no hidden field, no DOM.

- **React** (`@cross-deck/web/react`): `<CrossdeckTrust onToken={...} />` and the headless `useTrustToken()` hook.
- **Vue** (`@cross-deck/web/vue`): `useTrustToken()` composable.
- **Core**: `Crossdeck.trust.panel({ target, onToken })` (the publishable key comes from your `init()`), plus the framework-agnostic `mountTrustPanel()` for non-SDK use.

Pass the token to your server and verify it at the gate (`crossdeck.trust.gate({ email, ip, token })` in `@cross-deck/node`). **Bank-grade behaviour:** isolated (a stumble in the panel never throws into your app or breaks your signup form), origin-locked (only our panel window can deliver a token), and **fail-open, never fail-allow** — if the panel can't mint (adblocker, offline, our outage) you get `{ token: null }`, the signup proceeds, and the server scores the absent token. Failing *closed* would hand an attacker a site-wide-outage weapon, so we never do. The `<script src=".../embed.js">` loader remains the no-SDK / static-HTML path.

## [1.12.1] — 2026-07-24

**Republish of 1.12.0 — release-pipeline only, identical SDK.** 1.12.0 never reached npm: the release gate correctly failed it on the bundle budget, and by the time the budget fix landed the `v1.12.0` tag already existed on the public mirror, which refuses to overwrite a published artefact. Version bumped so the release can be cut cleanly. No SDK code changed — see 1.12.0 below for what actually shipped.

## [1.12.0] — 2026-07-24

**Bundle budget raised: core ESM/CJS 62 → 63 KB, UMD 35 → 36 KB (gzipped).** The first-touch origin persistence below adds ~0.5 KB of real code — the write-once store, the dual localStorage/cookie read-write, and the signal test. Core CJS measured 62.47 KB against a 62 KB budget and the UMD build 35.14 KB against 35 KB, so the release gate correctly stopped the publish. The budget is raised deliberately, with this note, per the gate's own rule — not silenced. Crossdeck still ships analytics + revenue + errors in roughly what single-purpose SDKs charge for one.

- **First-touch origin now survives the session boundary AND the subdomain hop.** Acquisition (`utm_*`, `referrer`, and the click ids `gclid`/`fbclid`/`msclkid`/`ttclid`/`li_fat_id`/`twclid`) was captured per session off the current URL, so a visitor who arrived from a campaign, returned later with no params, and then signed up converted with **empty** acquisition — the channel that won them was overwritten. And because `localStorage` is per-origin, a visitor acquired on a marketing site reached the app subdomain with no memory of the campaign at all, which is exactly where signup happens. The first touch carrying a real signal (any `utm_*` or click id) is now persisted **write-once** and restored whenever a later session lands with none, and it is written to **both** `localStorage` and the shared registrable-domain cookie — the same dual store as identity — so it crosses `example.com` → `app.example.com`.
- **`referrer` alone is deliberately not treated as a touch.** Every visit has one; counting it would freeze the first referrer permanently and stop any genuine campaign ever registering. `referrer` always describes the current visit.
- Not last-touch-wins: a newer campaign is the current touch for its own session, but never overwrites the stored first touch. Consent posture unchanged — with `persistIdentity: false` (or a `MemoryStorage` adapter) nothing is persisted and behaviour falls back to the current page.

## [1.11.2] — 2026-07-23

- **Reliability telemetry re-pointed to the live project.** The internal `crossdeck.contract_failed` self-check channel used a retired reliability-workspace key that had begun returning `401 invalid_api_key`, silently dropping failures; it now targets the live "Crossdeck Health Check" project. Internal SDK-health only — never touches your dashboard or your app.
- **`reset()` no longer re-mints the `anonymousId` when no user was ever identified.** It still clears session state (super-properties, groups, breadcrumbs, clock) but only rotates the anonymous id on a real logout (after `identify()`). This stops the common auth-mirroring wiring `onAuthStateChanged(u => u ? identify() : reset())` from fragmenting one anonymous visitor into a new person on every page load.

## [1.11.1] — 2026-07-23

**Republish of 1.11.0 — release-pipeline fix only, identical SDK.** 1.11.0 never
reached npm: the react/vue bundle crossed its size budget by ~70 bytes (they bundle
the core, which grew with the cross-subdomain identity feature). Budget raised
55 → 57 KB. No SDK change from 1.11.0 — see that entry.

## [1.11.0] — 2026-07-23

**Added — cross-subdomain identity (marketing ↔ app is now ONE person).** Until now
the anonymous-ID cookie was host-only, so a visitor on your marketing site
(`example.com`) and your app (`app.example.com`) were two different anonymous
people — first-touch source, journey, and conversion never stitched across the
hop. Now the SDK scopes that cookie to the **registrable domain** by default, so a
visitor is one identity across every subdomain of your site. The whole funnel —
anonymous marketing visit → true source (referrer/UTM) → signup → revenue → errors
— lands on one timeline.

- New `cookieDomain` option: `"auto"` (default — the registrable domain, detected
  the GA4 way: probe from the broadest candidate up, take the broadest the browser
  accepts, so public suffixes like `.co.za`/`.com` are skipped with no bundled
  list); an explicit domain (`".example.com"`); or `"none"` for the old host-only
  behaviour. No config needed for the common case — it just works.
- **Non-breaking for existing identities:** localStorage still wins on read, so a
  returning visitor keeps their id; it's simply also written to the shared domain
  cookie now. Node/native are unaffected (cookies + subdomains are browser-only;
  cross-*device* identity is resolved server-side by email/userId).

## [1.10.3] — 2026-07-22

**Fixed — browser-injected globals no longer blamed on your code.** A browser (not
your app) injects private globals into every page — `window.__firefox__` (Firefox
for iOS reader mode), `__gCrWeb` (Chrome for iOS), `zaloJSV2` (Zalo). Their content
script races page setup and throws in the page's own global scope, so the throwing
frame wears your URL (`https://you.app/x:1:19`, "global code") and looked like your
code. Now: when the message names one of these globals, every frame is marked
`in_app: false` (it's the browser's code, not yours), and the error is fingerprinted
by the injected global — so the whole class, across pages and both race shapes
(`.reader` vs `Can't find variable`), collapses into a single grouped, de-emphasised
issue instead of thousands of per-page reds. Same family as the `iabjs://` in-app
browser handling. No public API change.

## [1.10.2] — 2026-07-22

**First published 1.10.x — same SDK, release pipeline fully green.** 1.10.0/1.10.1
were staged but blocked in CI before publish (1.10.0: `npm@latest` engine mismatch;
1.10.1: the bundle-size budget, which the 1.10.x feature growth crossed by ~80 bytes
on core CJS). Both fixed (npm pinned to `npm@^11`; core budget raised 60 → 62 KB for
a ~2 KB margin). SDK contents are identical to 1.10.0 below — see that entry for the
campaign-arrival feature and the in-app-browser fixes.

## [1.10.1] — 2026-07-22

**Republish of 1.10.0 — same SDK, working release pipeline.** The 1.10.0 tag never
reached npm: the release runner upgraded npm via `npm@latest`, which had rolled to
12.x (Node ≥22) and hard-failed `EBADENGINE` on the Node 20 runner, before the
publish step. That's fixed (the workflow now pins `npm@^11`). This is the first
version to actually publish; the SDK contents are identical to 1.10.0 below —
see that entry for the campaign-arrival feature and the in-app-browser fixes.

## [1.10.0] — 2026-07-22

**Added — campaign-arrival connect (the HubSpot identity bridge).** When a page is
reached from a Crossdeck-tagged campaign link, the landing URL carries an opaque
`cd_ref` tag (the partner contact id — **never an email**). On session start the
SDK posts `{ anonymousId, ref }` once to the arrival endpoint, so the backend binds
this anonymous session to the tagged person and pulls their integration record
(deals, pipeline) onto their journey — no login required. A later real login still
converges (the anon anchor leaves `developerUserId` free — never a blind email
merge). Browser-only, fire-and-forget (never throws, never blocks init), and
backend-idempotent so a re-fire is harmless. Automatic; no new public method.

**Fixed — don't manufacture noise from in-app browsers.** Instagram/Facebook's
Android in-app browser injects its own script (`iabjs://…`) into its WebView and,
during page teardown, its Java bridge is garbage-collected and throws
`Error invoking …: Java object is gone`. Two changes so the SDK never turns the
host shell's own teardown into the developer's error:

- **Unload handlers are now exception-safe.** The SDK's terminal-flush and
  session-persist work fires on `pagehide` / `beforeunload` /
  `visibilitychange→hidden` — exactly when the in-app browser hooks browser APIs
  and throws from its own teardown. Every such handler (auto-track session
  persist, web-vitals flush, the shared unload flush) now swallows a throw at the
  handler boundary, so a host-shell teardown error can no longer propagate to
  `window.onerror` and be self-reported. The page is going away; nothing there can
  be meaningfully handled.
- **Injected in-app-browser frames are no longer marked `in_app`.** The stack
  parser treats `iabjs://` / `webview://` / `inappbrowser://` frames as
  third-party (like extension frames), so the dashboard's "your code vs library
  code" view no longer attributes the in-app browser's errors to the developer.

No new or changed public methods (campaign-arrival is automatic; the iabjs work is
internal robustness).

## [1.9.1] — 2026-06-30

**Docs — knowledge-backbone governance release.** No runtime API change. This
patch republishes the SDK with the Markdoc-backed README/version-control surface
so npm, the public GitHub mirror, and the Crossdeck knowledge backbone all carry
the same governed installation and contract documentation.

## [1.9.0] — 2026-06-24

**Added — read-cost cross-match (the Buckets bridge).** When
[`@cross-deck/buckets/web`](https://www.npmjs.com/package/@cross-deck/buckets) is
installed, `identify(userId)` now tells the browser collector who the session is,
so every browser database read attributes to that user; `reset()` clears it on
logout. A browser session is single-user, so the actor is session-level. Decoupled
and zero-dependency — the SDK drives a global bridge (`__crossdeckBucketsBridge__`)
and never imports Buckets; a missing collector is a silent no-op. The cross-match is
operational (like the entitlement cache), not an analytics event.

## [1.8.0] — 2026-06-11

**PARK on version-rejection — events are held, never dropped.** A third
event-queue outcome for the day the server stops accepting an outdated event
format. Purely additive; no public API change.

**Added:**

- **PARK (HTTP `426` / `sdk_version_unsupported`).** Previously a permanent 4xx
  dropped the batch. Now a version-rejection is recognised as its own outcome —
  distinct from retry (transient) and drop (invalid): the data is good, only the
  wire dialect is stale. The queue **holds** the events (folded to the front of
  the durable localStorage queue, FIFO-capped at 1000), **hushes** (stops
  flushing a known-too-old payload — no wasted battery/bandwidth), **signals**
  once (one `console.warn` + a typed `sdk.parked` debug event the dashboard
  reads), and **backfills** on the next page load after you upgrade. So "paused,
  not lost — held on-device, resumes on upgrade" is literally true.
- **`sdk_version_unsupported`** added to the error-codes catalogue with
  remediation, and `version_error` to `CrossdeckErrorType`. `CrossdeckError`
  now carries `minVersion` / `surface` (parsed from the 426 body) so the PARK
  message names the exact version to update to.
- New `onParked` queue callback (host → dashboard heartbeat channel).
- Bundle-size budget: UMD min 33 → 35 KB gzipped (~0.5 KB for the PARK branch +
  onParked + catalogue entry + minVersion/surface). core/react/vue unchanged,
  under budget.

See https://cross-deck.com/docs/sdk-event-durability/ for the full durability
contract.

## [1.7.0] — 2026-06-10

Event Envelope v1 conformance (`backend/docs/event-envelope-spec-v1.md`). Wire-breaking change (pre-launch; free). No public API change.

- **`envelopeVersion: 1`** (integer) added to every batch POST body (spec §1). Distinct from `sdk.version`; the server uses it to gate parsing and will refuse payloads without it once enforcement deploys.
- **`seq`** (number) on every event (spec §3). Per-session monotonic integer, assigned synchronously at `track()` time alongside `timestamp` (one sample). Counter lives in `AutoTracker`, resets to 0 at every session boundary (`session.started`, `resetSession()`, 30-min idle rollover), and persists across tab hide/show within a session. The deterministic tiebreak for events sharing a timestamp.
- **`context`** object on every event (spec §4). Device/platform facts (`os`, `osVersion`, `appVersion`, `sdkName`, `sdkVersion`, `locale`, `timezone`, `browser`, `browserVersion`) are promoted OUT of `properties` into a standardised top-level object. `properties` retains screen dimensions and all app-supplied properties. This aligns the Web SDK field-set with Swift (which ships `os`, `osVersion`, `sdkName`, `sdkVersion`, `locale`, `timezone`, `deviceModel`), closing the Q3 drift documented in the spec's Phase-0 audit matrix.
- Bundle-size budget: core ESM + CJS 58 → 60 KB gzipped (~0.5 KB for the envelope fields + browser/browserVersion detection; CJS landed at 58.26, over the old ceiling). react/vue ESM and UMD unchanged, comfortably under budget.

## [1.6.3] — 2026-06-01

Patch — error-capture noise reduction. The fetch wrapper raised an
`HTTPError(status 0)` for **every** failed request, including the opaque
`TypeError` an adblocker, an offline device, or an aborted navigation
produces. Per the Fetch spec these are indistinguishable from a real outage,
so `status 0` is the single biggest false-alarm source in browser error
tracking — a developer's own adblocker blocking a first-party asset would
page as a production error. No public API change.

**Status-0 network failures from unambiguous client noise are no longer
reported.** A new internal predicate suppresses three cases: `AbortError`
(navigation / explicit cancel), offline (`navigator.onLine === false`), and
**same-origin** failures — the page itself loaded from that origin, so it's
demonstrably reachable; a status-0 there is client-side blocking, not a
server fault. Genuine same-origin server failures still surface as 5xx on
the success path. **Cross-origin outages still report** — real signal is
kept, only the noise is dropped. The request breadcrumb still fires
regardless, so context is preserved on other errors.

## [1.6.2] — 2026-05-31

Session-boundary correctness, plus the per-platform contract runtime status
that had been sitting unreleased. No public API change; `session.ended`
emission timing changes as described, so read before upgrading if you key
downstream logic on it.

**A tab left open and idle, then used again, no longer stretches one session
across the gap.** `markActivity()` now rolls to a new session when an event
lands after the 30-minute inactivity window has lapsed — covering the case the
page-load and tab-return resume checks miss entirely (a tab kept open and idle,
then interacted with, with no visibility transition). A single stored session
can no longer contain a >30-minute gap.

**Returning to a long-idle tab no longer back-dates `session.ended`.** The
visibility-resume path emitted `session.ended` at the moment of return — more
than 30 minutes after the session's last real event — which itself opened an
intra-session gap. The prior session now ends implicitly (its end inferred from
its last event), consistent with the page-load resume path. If you key
downstream logic on `session.ended`, note it no longer fires on a >30-minute
tab-return.

**Per-platform contract runtime status + a 7th live verifier.** Each bundled

**Per-platform contract runtime status + a 7th live verifier.** Each bundled
contract now carries `runtimeVerified` — whether *this* SDK self-verifies it
at runtime vs. proving it in CI only. It is derived at build time from the
SDK's `STATIC_VERIFIERS` registry (never hand-set), so the registry can't
disagree with what actually runs. `CrossdeckContracts` consumers can read it
to distinguish "watch it pass live" from "CI-proven every release".

- New runtime verifier `sdk-error-codes-catalogue` (boot self-test: every
  backend wire code carries a description + resolution in the shipped
  catalogue). Web now self-verifies **7** contracts live.
- Bundle-size budget: UMD min 32 → 33 KB (~0.4 KB gzipped for the flag + the
  new verifier's frozen 15-code list). Other bundles unchanged, under budget.

## [1.6.0] — 2026-05-30

Minor — two autocapture fidelity fixes. No public API change; event
emission behaviour changes as described, so read before upgrading if you
have downstream logic keyed on `session.ended` timing.

**Sessions now survive full-page navigations.** Session state was
in-memory only, so on a multi-page site (where the SDK re-installs on
every navigation) one visit was split into a separate session per page —
each `session.ended` on `pagehide` landing at the same instant the next
page's `session.started` fired. Sessions are now persisted (id, start,
last-activity, first-touch acquisition) to the same storage adapter as
identity and **resumed** across page loads within a rolling 30-minute
inactivity window.

- `session.started` no longer fires on a resumed page load — only on a
  genuinely new session (first visit, or first load after >30 min idle).
- `session.ended` no longer fires on `pagehide` / `beforeunload` (a
  navigation is not a session end). It fires only on real 30-min
  inactivity or an explicit `Crossdeck.stop()`.
- The inactivity window is bumped by every tracked event (auto or
  custom), not just pageviews/clicks.
- Honours consent posture: with `persistIdentity: false` or a
  `MemoryStorage` adapter, the session is in-memory only (per-page, the
  prior behaviour).
- Continuity is same-origin (localStorage); cross-subdomain stitching is
  not yet handled.

**Click autocapture no longer mashes labels.** A click on a control that
wraps other controls or a content block (a card around several buttons, a
hero `<a>` around a heading + paragraph) used to collapse the whole
subtree into one string — `"Log inContinue with GoogleContinue with
Apple…"`, `"Tudo que você é,em um só link.Portfolio…"`. The resolver now
returns the control's own label (word boundaries preserved, decorative
`svg`/`style`/`script` skipped), or for a wrapper its direct label / the
first heading inside it, falling through to the selector rather than a
concatenation. Attribute precedence (`data-*` → aria → value → text →
title → img/svg) is unchanged.

Bundle-size budget for the core bundles raised 55 → 58 KB gzipped (ESM +
CJS) to fit the ~1 KB of new code; react/vue/UMD bundles unchanged and
still under budget.

## [1.5.1] — 2026-05-27

`crossdeck.contract_failed` is now single-fire to a dedicated
reliability endpoint instead of the customer's `track()` pipeline.
Independent-controller flow per Privacy Policy §6; schema-locked by
`contracts/diagnostics/contract-failed-payload-schema-lock.json`.
`ContractFailureInput.extra` removed (schema-lock forbids unbounded
fields); `ContractFailureInput.deviceClass` added.

**Runtime contract verifier layer.** The SDK now self-tests its
own structural contracts at runtime — per-user cache isolation,
idempotency-key determinism, error-envelope shape, flush-interval
parity, super-property merge precedence. Verifiers run on every
relevant SDK operation; PASS results stream to the developer's
console when `logVerifierResults: true`; FAIL results fire
`reportContractFailure(...)` to the reliability channel.

Three new `CrossdeckOptions` flags:
  - `verifyContractsAtBoot` — default dev=true, prod=false
  - `logVerifierResults` — default dev=true, prod=false (cosmetic only)
  - `disableContractAssertions` — sovereignty kill-switch, default false

Bundle-size budget bumped 45 → 55 KB gzipped (core) and 26 → 32 KB
(UMD) to accommodate the ~6 KB verifier framework + verifier
implementations. The platform-hardening signal — every install
in the field tests its own structural contracts as it operates and
reports failures to Crossdeck's reliability workspace in real time —
is the trade-off.

## [1.5.0] — 2026-05-26

Minor — `CrossdeckContracts` + `reportContractFailure(...)` ship as a
new public surface on every SDK simultaneously. Additive only; no
behavioural change to existing APIs.

**Added:**

- **`CrossdeckContracts` namespace** — typed, tree-shakeable access to
  the bank-grade contract registry the SDK was already shipping in
  `dist/contracts.json`. Methods: `all()` (enforced only),
  `allIncludingHistorical()`, `byId(id)`, `byPillar(pillar)`,
  `withStatus(status)`, `findByTestName(name)`. Properties:
  `sdkVersion`, `bundledIn` (e.g. `"@cross-deck/web@1.5.0"`).
- **`Contract` type + `ContractPillar` / `ContractStatus` /
  `ContractAppliesTo` unions + `ContractTestRef` interface** exported
  from the top-level entry. Treated as binary-stable — fields may be
  added in any minor release but never removed/repurposed except in a
  major bump.
- **`Crossdeck.reportContractFailure(input)` method** — fires a
  typed `crossdeck.contract_failed` custom event through the
  standard `track()` pipeline when a contract test asserts and
  fails (in CI, dogfood, or a customer integration test). Wire
  properties: `contract_id`, `sdk_version` (auto-stamped),
  `sdk_platform` (auto-stamped to `"web"`), `failure_reason`,
  `run_context` (`ci` | `dogfood` | `customer-app`), `run_id`, and
  optional `test_file` / `test_name` from `input.testRef`.
- **Bundle size**: core ESM/CJS/react/vue budgets raised to 45 KB
  gzipped (from 41 KB), UMD min to 26 KB (from 23 KB) to accommodate
  the inlined contracts dataset (~3 KB gzipped) + the query helpers
  + the new public types. Still well below every single-pillar
  competitor's ceiling (Mixpanel 55, Sentry 30 errors-only, PostHog
  40 analytics-only) for a one-bundle three-pillar SDK that now
  also ships its own verification dataset.

**Changed:**

- Contract registry source files migrated from snake_case to camelCase
  keys (`appliesTo`, `codeRef`, `testRef`, `registeredAt`,
  `firstRegisteredIn`). The bundled `contracts.json` sidecar shipped
  with this release uses the new keys. `bundledIn` is added at build
  time, never present in source. See [`contracts/README.md`](https://github.com/VistaApps-za/crossdeck/blob/main/contracts/README.md)
  for the schema rationale and `firstRegisteredIn` (immutable) vs
  `bundledIn` (build-stamped) split.

## [1.4.2] — 2026-05-26

Patch — second npm publish pipeline fix. v1.4.1 fixed the Node 24
`navigator` test mutation, but the `prepublishOnly` hook still ran
the Playwright e2e suite at `npm publish` time even though the
publish workflow doesn't install Chromium. Removed `test:e2e` from
`prepublishOnly` — the publish workflow runs lint + unit tests +
build + size budget which covers everything except the
browser-bound e2e (which requires Playwright setup the publish
workflow doesn't provide; e2e still runs in monorepo CI). v1.4.2
is the first 1.4.x line to actually land on the npm registry.
**No SDK code changes vs v1.4.0 / v1.4.1**.

## [1.4.1] — 2026-05-26

Patch — Node 24 compatibility fix for the npm publish pipeline. The
`consent.test.ts` DNT cases mutated `globalThis.navigator` via direct
assignment; Node 24 (the public crossdeck-web repo's npm publish
workflow Node version) made navigator a read-only getter, so the
test threw `TypeError: Cannot set property navigator` and aborted
the publish. Pattern switched to `Object.defineProperty`. v1.4.0
was tagged on the public GitHub repo but never reached npm — v1.4.1
is the first 1.4.x line to land on the npm registry. **No SDK code
changes vs v1.4.0**; the entire bank-grade reconciliation surface
documented below ships unchanged.

## [1.4.0] — 2026-05-26

**Bank-grade reconciliation release.** 6-pillar KPMG-style audit closed across SDK + backend. Every behavioural guarantee registered in the monorepo's `contracts/` directory with a CI-enforced audit job — drift is now a PR-time error.

### Added

- **Deterministic `Idempotency-Key` on `syncPurchases()`.** Derived from the request body (SHA-256 of `crossdeck:purchases/sync:<rail>:<jws>`, formatted as UUID). Same purchase → same key → backend short-circuits with `idempotent_replay: true`. Cross-SDK parity oracle CI-pinned: every SDK produces `a66b1640-efaf-bb4d-1261-6650033bf111` for the canonical test vector.
- **Per-user entitlement cache isolation.** Storage key is now `crossdeck:entitlements:<sha256(userId)>` — a user-switch on a shared device cannot physically read prior user's cached entitlements even if the in-memory clear is somehow skipped. `reset()` wipes EVERY per-user slot via the persisted index. New pure-JS SHA-256 helper (no SubtleCrypto async cascade through hot-path reads).
- **`PurchaseResult.idempotent_replay?: boolean`** — true when the response came from the backend's idempotency cache instead of fresh processing.
- **`purchase.completed` event on every successful `syncPurchases()`** — schema matches the auto-track event so cross-platform funnels reconcile.
- **15 backend-emitted error codes** added to `crossdeck-error-codes.json` catalogue (`invalid_api_key`, `origin_not_allowed`, `bundle_id_not_allowed`, `package_name_not_allowed`, `env_mismatch`, `idempotency_key_in_use`, `rate_limited`, `internal_error`, `google_not_supported`, `stripe_not_supported`, etc.) — `getErrorCode()` now returns Stripe-style remediation for every wire code instead of `undefined`.

### Changed

- **`init()` re-entry now drains the prior `EventQueue`'s pending timer** before swapping `this.state`. Pre-1.4.0 the timer fired AFTER the state swap, sending old-init events under new-init identity — cross-identity leak during HMR / config swap / multi-tenant SDK shells.
- **Default event-queue flush interval is now 2000ms** (was 1500ms) — parity with every other Crossdeck SDK on the Stripe-adjacent industry norm.
- **`reset()` now wipes every per-user entitlement slot on the device** via the persisted index, not just the active user's slot.

Patch fix for the 1.3.0 dist-load contract. 1.3.0 introduced
`import { version } from "../package.json"` to keep the runtime
`Crossdeck-Sdk-Version` header in lockstep with the published bundle.
Esbuild inlined the JSON correctly so the published bundle still
shipped the right version on the wire, but the `dist-loading` test
that dynamic-imports the built `.mjs` files was hitting Vitest's 5s
default test timeout while Node evaluated the bundle.

### Fixed

- **Removed the runtime JSON import.** `SDK_VERSION` is now sourced
  from a generated `src/_version.ts` file (produced by
  `scripts/sync-sdk-versions.mjs` from `package.json`). The wire
  contract is unchanged; the build artefact no longer carries a
  JSON-module dependency that Node ESM requires
  `with { type: "json" }` to load from a `.mjs` file.
- **dist-loading test timeout bumped to 60s.** The dynamic-imports of
  100KB+ bundles are genuinely slow on cold Node (~45s measured for
  `vue.mjs`); the assertions themselves are sub-millisecond.

1.3.0 was never published to npm; the only consumers are the public
GitHub repo's v1.3.0 tag (left in place for traceability). 1.3.1 is
the first 1.3.x line to reach npm.

## [1.3.0] — 2026-05-24

KPMG bank-grade audit closure. Six review batches landed five SDK PRs and a backend wiring fix that closes every P0 plus 12 of 13 P1 findings. No public method renames; one internal contract change (`ErrorTracker.beforeSend` is now a getter); behavioural changes to the queue and the PII scrub that strictly improve correctness. Default-safe: existing `Crossdeck.init({...})` callsites keep working exactly the same. The wire `Crossdeck-Sdk-Version` header now reads from `package.json` so it cannot drift from the published bundle.

### Fixed (P0)

- **PII scrub now walks NESTED objects.** Pre-fix `scrubPiiFromProperties` only scrubbed top-level keys plus 1-deep arrays of strings; nested plain objects passed through unchanged. Every `error.*` event ships nested `frames[]` / `breadcrumbs[]` / `context{}` / `http{}` — the leak surface was broad. New impl recurses into plain objects + arrays-of-objects. `Date` / `Map` / `Set` / `Error` instances + class instances pass through untouched (the property validator owns those shapes).
- **PII scrub sentinel tokens aligned with the backend.** `[email]` / `[card]` → `<email>` / `<card>`, matching `backend/src/api/lib/scrub.ts`. The same event scrubbed by SDK + backend now carries the same sentinel — dashboard aggregation works again.
- **`setErrorBeforeSend` installed AFTER init() now actually fires.** Pre-fix the `ErrorTracker` captured `beforeSend` by value at construction, so any hook a customer installed later was silently inert and their PII-redaction escape hatch ran on zero errors. Contract is now a getter; the tracker resolves the current hook on every report.
- **Event queue durability hole during flush.** Pre-fix the buffer was spliced + the persistent blob saved EMPTY before awaiting the network call — a hard-crash mid-flight wiped the persisted batch and the events were lost forever. New `pendingBatch` slot keeps the in-flight batch in the persisted blob until the server confirms it. Side benefit: retries now reuse the same `Idempotency-Key` (Stripe pattern, brings web in lockstep with node).
- **`identify()` cross-customer cache leak.** Pre-fix the entitlement-cache clear was gated on `priorCdcust && new && prior !== new`, missing two real scenarios where a previous user's entitlements leaked to a new login (ITP / partial cookie eviction wiped cdcust but left the cache; rehydration from a pre-persisted-identity legacy install). New contract: clear when the resolved cdcust differs OR the cache is non-empty under an unknown identity.
- **Error-capture self-skip derived from `baseUrl`.** Pre-fix hardcoded to `api.cross-deck.com`; customers on staging / regional / self-hosted relay base URLs recursed (5xx → captureHttp → enqueue → /events → captureHttp → ∞). Now strict-hostname compare against `selfHostname` extracted from `init({ baseUrl })`. Case-insensitive. Closes a subtle substring-match bypass (`api.cross-deck.com.attacker.example` would have matched).

### Added

- **`onPermanentFailure` callback on the event queue.** Fires when the queue drops a batch because the server returned a permanent 4xx (anything except 408 / 429). Loud `console.error` independent of debug mode, plus the new `sdk.flush_permanent_failure` debug signal. Pre-fix the queue retried 4xx forever with the same Idempotency-Key, silently growing the backlog while customers thought events were landing.
- **`onPermanentFailure({ status, droppedCount, lastError })`** is also exposed on the underlying `EventQueueConfig` for embedders wiring their own diagnostics surface.
- **Event-validation regression: DAG sibling sharing.** Two sibling properties pointing at the SAME sub-object no longer trip a false `[circular_reference]` flag. The validator now uses an ancestor-only stack instead of a shared `WeakSet` — real cycles still flag, legitimate DAGs pass through verbatim.

### Changed

- **`SDK_VERSION` is now imported from `package.json`.** The `Crossdeck-Sdk-Version` header always matches the published bundle. Pre-fix the constant drifted independently — the published 1.2.0 bundle reported `@cross-deck/web@1.1.0` on the wire because nobody bumped the literal.
- **4xx hard-stop on the event queue.** Status codes other than 408 / 429 in the 4xx range are NOT retryable; the queue drops the batch and surfaces it via `onPermanentFailure`. 408 / 429 / 5xx / network errors stay retryable. RFC-correct.
- **`Retry-After` is honoured even above `maxMs`.** Pre-fix the policy clamped server-supplied `Retry-After` to `maxMs` (60s default) — a `Retry-After: 120` got truncated to 60s and we hammered the rate limit twice as fast as asked. New 24h absolute sanity cap against server bugs / HTTP-date clock-skew.
- **`reset()` clears the clock-skew snapshot.** `diagnostics().clock.skewMs` no longer echoes the prior session's skew after logout.
- **`pageviewId` nulls on session boundary.** Pre-fix it survived 30-min idle resets and corrupted post-resume event → pageview correlation.
- **`init()` re-entry tears down prior listeners** (`uninstallUnloadFlush`, autoTracker, webVitals, errors). Pre-fix duplicate `pagehide` / `beforeunload` / `visibilitychange` listeners accumulated across HMR / config-swap calls.
- **PII scrub regex now uses `.replace()` unconditionally.** Dropped the `.test()`-gating that carried `lastIndex` state between calls; the gate could false-skip strings that actually matched. Same fix on both SDKs.
- **`isLocalHostname()` matches `0.0.0.0` and IPv6 `fe80::/10`** so webpack-dev-server / Vite dev defaults and cross-device Safari Web Inspector hostnames stop polluting live analytics.
- **Self-skip applies to breadcrumbs too**, not just `captureHttp`. Error reports no longer carry noisy `POST https://api.cross-deck.com/v1/events` crumb entries.
- **`syncPurchases` body spread bug.** Pre-fix `{ rail: input.rail ?? "apple", ...input }` — the `...input` ran LAST and overrode the default when the caller passed `rail: undefined` explicitly. Reversed: `{ ...input, rail }`.
- **Bundle-size budgets raised** to fit the durability + permanent-failure surface (~1.5 KB gzipped of bank-grade code). `core ESM` 33 → 35 KB, `core CJS` 34 → 36 KB, `react / vue ESM` 33 → 35 KB, UMD 18 → 19 KB. Still well under single-pillar competitor ceilings.

### Wiring (backend, paired)

- **`v1-events` ingest now honours the per-project `piiAllowList`.** The admin management surface (`v1-pii-allow-list.ts`) was persisted + audit-logged but the hot ingest path never read it. The new `backend/src/api/lib/pii-allow-list-cache.ts` (60s TTL, single-flight) feeds the project's allow-list to `scrubProperties()` on every batch. `HARD_LOCKED_PATTERNS` are always stripped from the effective list regardless of what's in storage. (Backend-only — listed here so SDK consumers know defence-in-depth is fully closed.)

## [1.1.0] — 2026-05-18

### Added

- **Durable last-known-good entitlement cache.** The entitlement cache is now persisted to device storage and re-hydrated synchronously on SDK boot, so `isEntitled()` is correct from the very first call for a returning customer — there is no cold-start window where a paying user flashes as free. A failed `getEntitlements()` never clears the cache; only a successful fetch replaces it, so a brief Crossdeck outage cannot fail a paying customer down to free. Each entitlement is still honoured against its own `validUntil`, so a timed-out trial still ends.
- **Cache staleness signal.** `diagnostics().entitlements.stale` flags when the cache is serving last-known-good after a failed refresh attempt, or after the data has aged past 24h — making serving-through-an-outage observable instead of a silent unbounded window.

### Changed

- **The entitlement cache is cleared on `reset()` and on an identity switch**, so a prior user's entitlements never leak to the next person on the same device.
- **Bundle-size budgets raised** to fit the durable cache. Device-storage persistence, boot hydration, and refresh-failure tracking are ~0.8 KB gzipped of real code. New gzipped budgets: `core ESM` 32 → 33 KB (32.81 KB actual), `core CJS` 33 → 34 KB (33.27 KB actual — CJS also carries CommonJS boilerplate the ESM build avoids), `react.mjs` / `vue.mjs` 32 → 33 KB. The UMD build holds at 18 KB. Still well under the single-pillar competitive ceiling — Sentry 30 KB (errors only), Mixpanel 55 KB (analytics only), PostHog 40 KB (analytics only) — for one bundle that ships all three pillars. See `sdks/web/scripts/check-bundle-size.mjs`.

## [1.0.1] — 2026-05-13

### Changed

- **Never silently surface an `Unknown error` again.** Non-`Error` throws (`throw { code: 500 }`, custom classes, a `Response` from `fetch`) now keep their type name, message field, and useful properties (`code` / `status` / `cause` chain). Cross-origin script errors land with a clear label and a `cross_origin: true` tag instead of being silently dropped. Distinct call sites no longer collapse into one `Unknown error` bucket. (PR #65 — `@cross-deck/node` 1.1.1 later ported the same hardening.)

## [1.0.0] — 2026-05-11

**Error capture — the third pillar.** Closes the trio: analytics +
revenue/entitlements + errors all ship in one SDK. After this
release the SDK covers every USP the platform sells. Bumped to
`1.0.0` because every pillar is now in the box.

Backwards-compatible: every Wave 1-4 API is unchanged. New error
APIs are additive. Source-compatible with 0.10.x — existing
`Crossdeck.init({...})` callsites work exactly the same.

### Added

- **Automatic uncaught-error capture.** Global `window.onerror` listener
  catches every uncaught synchronous error. Stack traces parsed into
  normalised frames (Chrome / Firefox / Safari). Reported as
  `error.unhandled` Crossdeck events through the same durable +
  retried + idempotent queue as analytics.
- **Automatic promise-rejection capture.** Global
  `window.onunhandledrejection` listener catches unhandled async
  failures. Reported as `error.unhandledrejection`.
- **Automatic HTTP-failure capture.** `fetch()` and
  `XMLHttpRequest` are wrapped to detect 5xx + network failures the
  app code didn't catch. Reported as `error.http`. Crossdeck's own
  API calls are explicitly excluded so a Crossdeck outage doesn't
  self-amplify into the queue.
- **`Crossdeck.captureError(err, { context, tags, level })`** — manual
  capture from try/catch blocks. Sentry pattern.
- **`Crossdeck.captureMessage(message, level)`** — non-error signals
  ("we hit the deprecated path", "soft warning"). Reported as
  `error.message`.
- **`Crossdeck.setTag(key, value)` / `Crossdeck.setTags(tags)`** —
  flat key/value labels attached to every subsequent error report.
- **`Crossdeck.setContext(name, data)`** — structured named context
  attached to every subsequent error report (Sentry pattern).
- **`Crossdeck.addBreadcrumb(crumb)`** — custom breadcrumb for the
  rolling buffer.
- **`Crossdeck.setErrorBeforeSend(hook)`** — pre-send filter; return
  null to drop, or a modified `CapturedError` to scrub fields. The
  only way to redact app-specific PII (auth tokens in URLs, etc.)
  before the report leaves the browser.
- **Breadcrumb ring buffer.** Every analytics event auto-emits a
  breadcrumb. The last 50 are attached to every error report so the
  engineer reading the error sees exactly how the user got into the
  broken state. Cleared on `reset()` / `forget()`.
- **Fingerprinting.** Every error gets a stable 8-character hex
  fingerprint (`djb2` of message + top 3 in-app frames). Dashboard
  uses this to group identical errors so 1,000 occurrences of the
  same bug show as 1 issue, not 1,000.
- **Rate limiting.** Per fingerprint: max 5 reports per minute.
  Defends against runaway loops (e.g. error in `setInterval`).
  Hard session cap: 100 errors total. After that, capture stops
  until the next session — the developer is told via Sentry
  receiving "1 unique error" instead of "1 million events".
- **Noise filtering.** Default `ignoreErrors` strips well-known
  browser noise (`ResizeObserver loop limit exceeded`, `Script
  error.`, etc.). Default `denyUrls` strips browser-extension
  frames (`chrome-extension://`, `moz-extension://`, etc.).
- **`autoTrack.errors: boolean`** flag (default true). Disable if
  you have a separate error tracker (Sentry, Bugsnag) and don't
  want duplicates.
- **`consent.errors`** dimension (already in 0.10.0 for Web Vitals)
  now ALSO gates error reporting. `consent({ errors: false })`
  silently drops every error event.
- **PII scrub** runs on every error payload (stack strings, URLs,
  context blobs) before they leave the browser — same regex pass
  as the analytics path.
- **New error code** in `CROSSDECK_ERROR_CODES` for the
  request_timeout / fetch_failed family already covered.
- **47 new tests** (306 total, up from 260):
    - `tests/breadcrumbs.test.ts` — 6 cases.
    - `tests/stack-parser.test.ts` — 13 cases covering Chrome /
      Firefox / Safari formats + in-app detection + fingerprinting.
    - `tests/error-capture.test.ts` — 21 cases covering captureError,
      captureMessage, filtering, rate limiting, sampling, beforeSend
      hook, context/tags attachment, breadcrumb snapshot, consent
      gating.
    - `tests/crossdeck.test.ts` — 7 new integration cases.
    - `tests/dist-loading.test.ts` — extended to assert the new
      public methods exist on the built artefact.
    - `e2e/smoke.spec.ts` — 5 new Playwright cases covering real-
      browser error capture (manual captureError, uncaught
      window.onerror, captureMessage, breadcrumb attachment,
      consent gate).

### Changed

- **Bundle-size budgets bumped** to account for the new pillar:
  core ESM / CJS / React / Vue from 28 KB → 32 KB; UMD from
  16 KB → 18 KB. The full SDK now ships at ~30 KB gz —
  comparable to Sentry's `@sentry/browser` *alone* (which doesn't
  include analytics or revenue). All three pillars in one bundle.
- `AutoTrackOptions` extended with `errors: boolean`.
- `track()` now gates `error.*` events on `consent.errors` (in
  addition to the existing `webvitals.*` gate); everything else
  continues to gate on `consent.analytics`.

### Compatibility

Source-compatible with 0.10.x. No public API removed. The new error
capture is on by default — applications that already have Sentry
installed should set `autoTrack: { errors: false }` to avoid
duplicate reporting.

## [0.10.0] — 2026-05-11

**Privacy + compliance + operational pass (Waves 3 + 4).** Locks down GDPR / CCPA support, ships the CDN + framework story, and publishes the error-code surface that Stripe-style integrators depend on. Backwards-compatible — every new field defaults to "don't change behaviour". Source-compatible with 0.9.x.

### Added

- **`Crossdeck.consent({ analytics, marketing, errors })`** — three independent consent dimensions, each defaulting to `true` (granted). Gates `track()`, `identify()`, paid-traffic click IDs, referrer URLs, and Web Vitals appropriately. `Crossdeck.consentStatus()` returns the current snapshot.
- **`respectDnt: true`** in `init()` — opt-in DNT support. When the browser exposes `navigator.doNotTrack === "1"`, ALL three consent dimensions are locked OFF permanently (no subsequent `consent()` call can flip them back on).
- **`scrubPii: true`** (default-on) in `init()` — Stripe-grade regex pass over every event property value, URL path, and title before flush. Email-shaped → `<email>`, card-number-shaped → `<card>` (tokens aligned with the backend's defence-in-depth scrubber). The walk is recursive: nested plain objects + arrays-of-objects are visited. Caller's input is never mutated. Disable for pipelines that do their own redaction.
- **`Crossdeck.forget(): Promise<void>`** — GDPR / CCPA right to be forgotten. Calls the new `/v1/identity/forget` endpoint and wipes ALL local state. Idempotent. Server-side failure does NOT block local wipe.
- **`@cross-deck/web/vue` subpackage** — Vue 3 composables (`useEntitlement(key)` → `Ref<boolean>`, `useEntitlements()` → `Ref<string[]>`) that mirror the React subpackage's contract. Subscribes to the entitlement cache via `onEntitlementsChange`. SSR-safe.
- **UMD CDN bundle** — `dist/crossdeck.umd.min.js`, registered via `unpkg` / `jsdelivr` package.json fields. Exposes `window.Crossdeck` for no-build-step consumers (plain HTML, Webflow, docs). 13 KB gzipped.
- **`CROSSDECK_ERROR_CODES` + `getErrorCode(code)`** — machine-readable index of every error code the SDK can throw, with `description`, `resolution`, and `retryable` flag. Also emitted as `dist/error-codes.json` sidecar. Stripe pattern.
- **Bundle-size budget enforcement** — `npm run size` (also runs in `prepublishOnly`) fails the release if any artefact exceeds its gzipped budget. Current ceilings: 28 KB for core / framework subpackages, 16 KB for UMD.
- **New debug signals:** `sdk.consent_changed`, `sdk.consent_denied`, `sdk.consent_dnt_applied`, `sdk.pii_scrubbed`.

### Backend changes (paired)

- **`POST /v1/identity/forget`** — new endpoint. Resolves the customer from any identity hint, sets `forgottenAt: now` on the customer record, queues a `forgetRequests` row for the retention-cleanup worker to drain.
- **`POST /v1/identity/alias`** — now accepts optional `traits` in the body and persists them under `customers/{cdcust}.traits` additively (per-key merge). Defence-in-depth sanitisation server-side: max 32 keys, 1 KB per value, primitives only.

### Compatibility

Source-compatible with 0.9.x. The new defaults (`scrubPii: true`, `respectDnt: false`) preserve existing analytics shape for current consumers. The Vue subpackage adds an optional peer dependency declared in `peerDependenciesMeta` — non-Vue consumers don't install it.

## [0.9.0] — 2026-05-11

**Data completeness pass (Wave 2).** Closes the gap between Crossdeck's event surface and Mixpanel / Segment / Amplitude. Backwards-compatible — `Crossdeck.init({...})` callsites don't need to change; the new APIs are additive.

### Added

- **`Crossdeck.identify(userId, { traits })`** — accept profile traits (name, plan, signupDate, role) alongside the email field. Traits are sanitised at the SDK boundary and persisted server-side on the customer record under `customers/{cdcust}.traits` (per-key merge, additive — a later identify call with `{ plan: "pro" }` doesn't wipe a prior call's `{ name: "Wes" }`). Defence-in-depth server validation: max 32 keys, 1 KB per value, primitives only.
- **`Crossdeck.register(properties)` + `unregister(key)` + `getSuperProperties()`** — Mixpanel "super properties" pattern. Set keys once, attached to every subsequent event of this SDK instance. Null value deletes a key. Persists across page reloads via the identity storage; cleared on `reset()` / `forget()`.
- **`Crossdeck.group(type, id, traits?)` + `getGroups()`** — Mixpanel / Segment "Group Analytics". Each event carries `$groups.<type>: id` for B2B SaaS dashboards. Multiple types coexist (`org` + `team` + `plan`). Pass `id: null` to clear a group membership.
- **Paid-traffic click ID capture** — `gclid` (Google Ads), `fbclid` (Meta), `msclkid` (Microsoft), `ttclid` (TikTok), `li_fat_id` (LinkedIn), `twclid` (X / Twitter). Captured at session start alongside UTMs, attached to every event of the session.
- **`pageviewId`** — stable per-page-view identifier minted on every `page.viewed` and attached to every subsequent event until the next `page.viewed`. Mixpanel's `$current_url`-style correlation — lets dashboards answer "user clicked X on page Y" without timestamp arithmetic.
- **Web Vitals capture** — `webvitals.lcp`, `webvitals.inp`, `webvitals.cls`, `webvitals.fcp`, `webvitals.ttfb` events emitted via `PerformanceObserver`. LCP / CLS / INP flush at page hidden (final values only known after user activity stops). New `autoTrack.webVitals` flag (default true). Hand-rolled (~120 lines), zero runtime deps.

### Changed

- `IdentifyOptions` extended with `traits?: Record<string, unknown>`. Existing `email`-only callers unaffected.
- `AutoTrackOptions` extended with `webVitals: boolean`. Existing `init()` callsites without `autoTrack` get it default-on.
- `SessionAcquisition` extended with the six paid-traffic click-ID fields. Existing acquisition consumers unaffected — fields are empty strings when not present.

### Compatibility

Source-compatible with 0.8.x. No public API removed. Every new field defaults to a sensible value that preserves the previous behaviour for existing callsites.

## [0.8.0] — 2026-05-11

**Bank-grade plumbing pass (Wave 1).** Six closely-coupled hardenings that bring the SDK's reliability surface up to Stripe / Segment / Mixpanel standards. Backwards-compatible: no public API removed, every new option has a sensible default, every behaviour change is additive. Source-compatible with 0.7.x — `Crossdeck.init({...})` callsites do not need to change.

### Added

- **Durable event queue.** Queued events are now written through to the SDK's identity store (typically `localStorage`) so a hard browser crash, power loss, or terminal-flush `keepalive: true` cap exceedance (64 KB) doesn't lose data. On the next SDK boot the persisted queue is rehydrated and replayed. Backend dedupes by `eventId` so a replayed event already on the wire when the tab crashed is safe — `ReplacingMergeTree` handles it. New module `event-storage.ts` (`PersistentEventStore`). Skipped when `persistIdentity: false` (strict-consent flows).
- **Exponential backoff with full jitter on flush failures.** Replaces the prior "retry on the next idle window" policy which hot-looped a flapping endpoint. Defaults: `baseMs=1000`, `factor=2`, `maxMs=60000`. Each failure schedules the next flush at `min(maxMs, baseMs * 2^attempts) * Math.random()` ms out. Reset on success. Surface via `diagnostics().events.consecutiveFailures` + `nextRetryAt`. New module `retry-policy.ts` (`RetryPolicy`, `computeNextDelay`).
- **`Retry-After` header support on 429 / 503.** The HTTP layer now parses the header (delta-seconds or HTTP-date per RFC 7231 §7.1.3) onto `CrossdeckError.retryAfterMs`, and the retry policy honours it when it's longer than the computed backoff. Stripe pattern — the server is the authority on its own pressure.
- **`Idempotency-Key` header per batch.** Every `/v1/events` POST now carries `Idempotency-Key: batch_<rand>`. Retries of the SAME logical batch reuse the SAME key so a future server-side idempotency layer can short-circuit duplicate work without inspecting bodies. Per-event `eventId` dedup remains in place — this is belt-and-suspenders.
- **Request timeout via `AbortController`.** New `timeoutMs` option on `CrossdeckOptions` and per-request `options.timeoutMs` on `HttpClient.request()`. Default 15 000 ms. Without this, a captive portal / DNS hang / satellite link could leave a request open for the browser's default (5+ minutes on Chrome) and lock the queue forever. Pass `timeoutMs: 0` to disable (useful for tests). New error: `CrossdeckError({ type: "network_error", code: "request_timeout" })`.
- **Property validation at enqueue.** `track(name, properties)` now sanitises `properties` BEFORE the event lands in the queue. New module `event-validation.ts`. Behaviour:
    - **Drops** functions, symbols, undefined values (with a debug warning).
    - **Coerces** `Date` → ISO string, `BigInt` → string, `Error` → `{ name, message, stack }`, `Map` → plain object, `Set` → array.
    - **Truncates** string values longer than `maxStringLength` (default 1024) with an ellipsis.
    - **Replaces** circular refs with `"[circular]"` and depth > 5 nesting with `"[depth-exceeded]"`.
    - **Caps** total per-event property byte size at `maxBatchPropertyBytes` (default 8 KB); past the cap, largest properties drop first and a `__truncated: true` marker is added.
    - Caller's input is never mutated — sanitisation always produces a defensive copy.
    - Output is guaranteed `JSON.stringify`-safe. One bad property can no longer poison the entire batch indefinitely.
- **Listener-error counter on `EntitlementCache`.** Listener exceptions are still swallowed (a buggy consumer must not crash the SDK) but the cumulative count is now surfaced as `diagnostics().entitlements.listenerErrors` so a broken subscriber can be spotted without a debug session.
- **Clock-skew diagnostics.** `Crossdeck.heartbeat()` now captures the server's `serverTime` and the local `Date.now()` at the same moment. Surfaces via `diagnostics().clock.{lastServerTime, lastClientTime, skewMs}` so a wrong-system-clock problem (kid changed the date, dev machine bad NTP) surfaces in dashboards before it corrupts a day of analytics.
- **New debug signals:** `sdk.property_coerced`, `sdk.queue_persisted`, `sdk.queue_restored`, `sdk.flush_retry_scheduled`. Fire in debug mode only — quiet by default.
- **65 new tests** (203 total, up from 138):
    - `tests/event-validation.test.ts` — 19 cases covering every coercion / drop / truncation / depth / size-cap path + JSON-roundtrip + no-mutation guarantee.
    - `tests/event-storage.test.ts` — 8 cases covering load / save round-trip, debouncing, malformed-blob recovery, version sentinel, throwing-storage degradation.
    - `tests/retry-policy.test.ts` — 12 cases covering backoff math, jitter, Retry-After precedence, attempt overflow safety, counter reset.
    - `tests/event-queue.test.ts` — 9 new cases covering Idempotency-Key uniqueness, retry scheduling, server Retry-After honouring, durable rehydration, write-through, persistent clear on success, reset() wipe.
    - `tests/http.test.ts` — 5 new cases covering Idempotency-Key passthrough, abort-timeout behaviour, per-call timeout override, 0-disables-timeout, Retry-After parse onto `retryAfterMs`.
    - `tests/errors.test.ts` — 9 new cases covering `parseRetryAfterHeader` for delta-seconds, HTTP-date, past dates, malformed input.
    - `tests/entitlement-cache.test.ts` — 1 new case covering the listener-error counter.
    - `tests/crossdeck.test.ts` — 1 new case asserting the full Wave-1 diagnostic surface.

### Changed

- `CrossdeckError` now carries an optional `retryAfterMs` field, populated from the response's `Retry-After` header on 4xx/5xx.
- `Diagnostics` shape extended with:
    - `clock: { lastServerTime, lastClientTime, skewMs }`
    - `entitlements.listenerErrors: number`
    - `events.consecutiveFailures: number`, `events.nextRetryAt: number | null`
- Existing `Diagnostics` fields and their semantics are unchanged.

### Migration

No callsite changes required. New options (`timeoutMs`, retry tuning) default to sensible bank-grade values. To opt out of property validation, pass already-clean property objects — there's no escape hatch, and there shouldn't be: an SDK that lets one bad event poison the whole batch isn't bank-grade.

## [0.6.0] — 2026-05-10

Bank-grade analytics enrichment. Two additive changes that close the gap between Crossdeck's analytics surface and Google Analytics 4 / Google Ads dashboards: identity continuity that survives cleared storage, and first-touch acquisition attribution attached to every event of a session. No public API changes — `Crossdeck.init({...})` callsites do not need to change.

### Added

- **Identity continuity — dual-store redundancy.** The SDK now writes `anonymousId` and `crossdeckCustomerId` to BOTH `localStorage` (primary) and a 1st-party `document.cookie` (secondary). On boot it reads both and prefers primary; if primary is empty, it recovers from the cookie and resyncs primary. This protects against ITP localStorage purges, "clear site data" actions, and aggressive privacy extensions — a returning user keeps the same Crossdeck identity instead of becoming a phantom new visitor on dashboards. See `sdks/SDK_TRUTH.md` § "Identity continuity — bank-grade redundancy" for the full contract.
- **`CookieStorage` adapter** in `storage.ts`. Sets `Path=/`, `Max-Age=63072000` (2y), `SameSite=Lax`, `Secure` (when over HTTPS — omitted on `http://localhost` so dev works without a TLS cert). Encodes/decodes cookie names + values defensively so embedded `;` and `=` survive round-trip.
- **First-touch acquisition capture in `AutoTracker`.** On every `session.started` the SDK reads `window.location.search` and `document.referrer` and captures `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, plus `referrer`. Non-empty values are auto-attached to every subsequent event of that session — matching GA4's session-pinned attribution semantics. SPA route changes mid-session do NOT re-read the URL; a new session (>30 min idle, or explicit `resetSession()`) re-captures off the current URL.
- **`AutoTracker.currentAcquisition`** getter. Returns the captured-once-per-session acquisition context for inspection / tests / framework bindings. Returns empty strings (not undefined) when there's no active session so callers can spread without conditional logic.
- **`captureAcquisition()` exported** from `auto-track.ts` for unit testing acquisition extraction in isolation.
- **18 new tests** (138 total, up from 120):
    - `storage.test.ts` — 6 cases covering `CookieStorage` round-trip, URL-encoding survival, attribute emission (Path / SameSite / Max-Age / Secure on HTTPS, Secure-omitted on HTTP), null on broken cookies, no-op in Node (no `document`).
    - `identity.test.ts` — 6 cases covering the redundancy contract: writes-to-both, recovery from secondary when primary cleared, recovery from primary when secondary cleared, primary-wins-on-conflict, set/reset both, defence-in-depth against a throwing secondary.
    - `auto-track.test.ts` — 5 cases: `captureAcquisition` reads utm_*, returns empty for clean URLs, `currentAcquisition` is session-pinned (SPA navigation does NOT change it mid-session), `resetSession` re-captures off the current URL, returns empty when no session exists.

### Server-side enrichment (lands without an SDK upgrade)

The 0.6.0 SDK pairs with these backend changes that started populating ClickHouse columns ahead of this release — every existing 0.5.0 install starts seeing them in dashboards immediately:

- **Geography** — `events.country` populated from the Cloudflare `CF-IPCountry` header at `/v1/events`. Server-decided, not client-trusted.
- **New vs returning** — `events.is_new` populated by a Firestore-transactional `visitors/{anonymousId}` upsert in the ClickHouse projector. First event for a new anonymousId wins the race; concurrent inserts converge.
- **Device hoist** — `events.browser`, `events.os`, `events.device_class` hoisted out of `properties_json` to first-class LowCardinality columns for fast slicing.
- **Acquisition columns** — `events.utm_source`, `events.utm_medium`, `events.utm_campaign`, `events.utm_content`, `events.utm_term`, `events.referrer_host` populated from event properties (which the 0.6.0 SDK now sends; pre-0.6.0 events get empty strings).
- **Sessions** — `sessions` table aggregates the same enrichment columns via `any` / `max` (for `is_new`) so per-session breakdowns don't have to fan out across raw events.
- ClickHouse migration `006_analytics_columns.sql` is idempotent and additive — old rows already in `events` keep working with empty / 0 defaults.

### Privacy posture

Privacy posture is unchanged from single-store identity. The cookie holds only the same `anonymousId` already in `localStorage` — no fingerprintable data, no PII. Anything that can read `localStorage` on the same origin can read this cookie; the security model is identical to Stripe, Segment, and PostHog's 1st-party identity cookies. `persistIdentity: false` continues to disable all persistence (in-memory only) for customers running strict consent flows.

### Compatibility

Source-compatible with 0.5.0. No public API changes. No deprecated symbols. Existing snippets do not need to change.

## [0.4.0] — 2026-05-09

Reactive entitlements. Pre-0.4.0, calling `Crossdeck.isEntitled("pro")` directly inside a React render path showed the empty-cache result forever — React had no way to know the cache had populated asynchronously after `init()`. This release closes that gap with a first-class subscribe API on the SDK and a React subpackage that uses it.

### Added

- **`Crossdeck.onEntitlementsChange(listener)`** — synchronous subscribe API. Returns an idempotent unsubscribe function. Listeners fire AFTER each cache mutation (`getEntitlements`, `syncPurchases`, `reset`). Listener errors are swallowed. NOT fired on subscribe — read state inline if you need the initial value. See `sdks/SDK_TRUTH.md` for the full contract.
- **`@cross-deck/web/react` subpath export** — first-class React hooks built on top of the subscribe API:
    - `useEntitlement(key): boolean` — re-renders the component the moment the cache mutates so a JSX snippet like `useEntitlement("pro") && <ProBadge />` actually works.
    - `useEntitlements(): readonly string[]` — reactive list of all active entitlement keys.
  - SSR-safe: hook returns `false` / `[]` on the server and hydrates correctly on the client. Pre-init returns the empty default until `Crossdeck.init()` runs and a cache mutation lands.
- **`EntitlementCache.subscribe(listener)`** — internal listener API on the cache itself. Powers `onEntitlementsChange`. Iterates over a snapshot of the listener set so listeners that unsubscribe themselves during dispatch don't break the iteration.
- **Tests** — 7 new cases covering listener semantics: fires on `setFromList`, fires on `clear`, NOT fired on subscribe, idempotent unsubscribe, listener errors are non-fatal, self-unsubscribe-during-dispatch is safe.

### Why this exists

Without a subscribe API, every framework binding (React, SwiftUI, Compose, Vue, Solid) had to invent its own re-render trigger by polling or hooking into private SDK internals. The cache is the only place that knows precisely when `isEntitled()` would change its answer; making it the source of the notification is the correct contract. iOS and Android SDKs MUST adopt the same pattern internally before 1.0 and MUST expose framework bindings (`@Observable` / SwiftUI for iOS, `StateFlow<Boolean>` / Compose for Android) that mirror the React hook's semantics. See the SDK NorthStar Addendum §11.4.

### Build

- `tsup` now emits two entry points (`dist/index.{cjs,mjs}` and `dist/react.{cjs,mjs}`) with a custom `outExtension` matching the `package.json` exports map.
- React is now an optional peer dependency (`react >=18`).

### Compatibility

Source-compatible with 0.3.0. No breaking changes — `onEntitlementsChange` and the React hooks are purely additive.

## [0.3.0] — 2026-05-08

This release reconciles the web SDK with the Crossdeck SDK NorthStar Addendum (§4 Shared Contract, §11.1 Web SDK pattern, §13.1 wire envelope, §15 sensitive properties, §16 debug signal vocabulary). The public surface now matches what the iOS, Android, and Node SDKs will expose — `init`, `flush`, `syncPurchases`, `setDebugMode`.

### Added

- **`Crossdeck.init({ appId, publicKey, environment })`** — canonical lifecycle method per NorthStar §4. The trio is required and validated up-front: a publishable-key prefix that disagrees with the declared `environment` throws `CrossdeckError({ code: "environment_mismatch" })` at boot, so a typo can't silently route prod data into sandbox dashboards.
- **`Crossdeck.flush()`** — alias of the old `flushEvents()`, matching the standardised name.
- **`Crossdeck.syncPurchases(input)`** — replaces `purchaseApple`. Posts to `/v1/purchases/sync` and accepts an optional `rail` field for future Stripe/Google support.
- **`Crossdeck.setDebugMode(enabled)`** + `debug` init option — toggle the §16 debug signal vocabulary (`sdk.configured`, `sdk.first_event_sent`, `sdk.no_identity`, `sdk.purchase_evidence_sent`, `sdk.environment_mismatch`, `sdk.sensitive_property_warning`).
- **Sensitive-property warnings** — when debug mode is on, `track()` warns once per call if any property key matches `email|password|token|secret|card|phone` (NorthStar §15). The event is still sent unmodified; the warning surfaces accidental PII in the dashboard onboarding feed.
- **NorthStar §13.1 wire envelope** — every `/v1/events` POST now includes `appId`, `environment`, and `sdk: { name, version }` at the batch level. The backend validates these against the API-key-resolved app and rejects mismatches with `permission_error / env_mismatch`.

### Changed

- `Crossdeck.start()` is now a deprecated alias of `init()` and emits a `console.warn` once per call. The signature is unchanged, but the new `appId` and `environment` options are still required even when calling `start`.
- `Crossdeck.purchaseApple()` is now a deprecated alias of `syncPurchases({ rail: "apple", ... })`. The new method posts to `/v1/purchases/sync`; the legacy `/v1/purchases` route is kept on the backend for v0.2.x callers.
- The `not_started` configuration error code is now `not_initialized` to match the rename.

### Removed

Nothing. v0.3.0 is fully source-compatible with v0.2.x callers — the legacy method names log a deprecation but continue to work. Plan to drop them in v0.5.0.

## [0.2.0] — 2026-05-06

- Added auto-tracking: sessions, page views, and device-info enrichment are on by default in browsers. See `autoTrack` config to disable individually or wholesale.
- Stable `Diagnostics` shape regardless of whether `start()` has been called — pre-start values are sensible empties.

## [0.1.0] — 2026-05-05

Initial public release.
