# Crossdeck SDK — Source of Truth

**Single reference for every surface that mentions the Crossdeck SDK.** If a snippet, prompt, doc page, or diff disagrees with anything here, this file wins. Update this file in lockstep with `sdks/web/src/crossdeck.ts` and `sdks/web/src/types.ts`.

The web SDK (`@cross-deck/web`) is the canonical, shipping implementation. iOS and Android are "Coming soon" — their snippets must MIRROR the web SDK's design philosophy verbatim until they ship.

---

## Package + key naming

| Concept           | Canonical form                                    |
| ----------------- | ------------------------------------------------- |
| Web npm package   | `@cross-deck/web` (hyphenated scope)              |
| iOS Swift Package | `Crossdeck` (URL: github.com/VistaApps-za/crossdeck-ios — coming soon) |
| Android Maven     | `app.cross-deck:crossdeck:<version>` (coming soon) |
| App ID            | `app_web_<rand>` / `app_ios_<rand>` / `app_android_<rand>` |
| Publishable key   | `cd_pub_test_<rand>` (sandbox) / `cd_pub_live_<rand>` (production) |
| Secret key        | `cd_sk_test_<rand>` (sandbox) / `cd_sk_live_<rand>` (production) — server only |
| Environment value | `"sandbox"` or `"production"` — exactly those strings, no others |

The publishable key prefix is normative. The SDK's `inferEnvFromKey()` in `crossdeck.ts` only recognises `cd_pub_test_` and `cd_pub_live_`. Any other env-suffix (e.g. legacy `cd_pub_sandbox_…`) bypasses the env-mismatch guard at init.

---

## Public API — `@cross-deck/web`

All methods are on the `Crossdeck` singleton exported from `@cross-deck/web`. They are also available on any `new CrossdeckClient()` instance.

### `Crossdeck.init(options): void`

Boot the SDK. Synchronous, idempotent. Validates the trio (`appId`, `publicKey`, `environment`) up front and rejects mismatched key/env pairs at boot.

Required options: `appId`, `publicKey`, `environment`. Optional: `baseUrl`, `appVersion`, `autoTrack`, `autoHeartbeat`, `eventFlushBatchSize`, `eventFlushIntervalMs`, `storage`, `storagePrefix`, `persistIdentity`, `sdkVersion`, `debug`, `timeoutMs`.

- `timeoutMs` (default `15000`) — per-request abort timeout. A captive portal or hung connection would otherwise inherit the browser's default (5+ minutes on Chrome) and stall the queue. Pass `0` to disable; per-call overrides allowed via the HTTP layer. Lands as `CrossdeckError({ type: "network_error", code: "request_timeout" })` on expiry.
- `respectDnt` (default `false`) — when `true`, the SDK reads `navigator.doNotTrack` at init and locks every consent dimension OFF if the user has DNT enabled. The deny is permanent — subsequent `Crossdeck.consent({...})` calls cannot flip it back on. Industry has effectively deprecated DNT; opt-in support is the polite default for privacy-first apps.
- `scrubPii` (default `true`) — runs a regex pass over every event property value, URL path, and title before flush; replaces email-shaped substrings with `[email]` and card-number-shaped sequences with `[card]`. Stripe-grade defence in depth. Disable only when your pipeline does its own redaction downstream.
- `autoTrack.errors` (default `true` in browsers) — installs global `window.onerror` + `window.onunhandledrejection` listeners, wraps `fetch` and `XMLHttpRequest` to catch 5xx + network failures, and reports each captured error as a Crossdeck event. Set `false` if you have a separate error tracker (Sentry, Bugsnag) and don't want duplicates.

Pitfalls:
- The key prefix must match the declared environment. `cd_pub_test_` ↔ `"sandbox"`, `cd_pub_live_` ↔ `"production"`. Mismatch throws `CrossdeckError({ code: "environment_mismatch" })`.
- `Crossdeck.start(options)` is a deprecated alias — never use in new code.

### `Crossdeck.identify(userId, options?): Promise<AliasResult>`

Async. Links the anonymous device to your developer-side stable user ID. Returns the resolved Crossdeck customer.

Pitfalls:
- Always wire to your real auth provider. Never hardcode a placeholder like `"user_123"` — that aliases every visitor to the same fake user.
- Optional `options.email` attaches an email to the customer record.
- **v0.9.0+** optional `options.traits` (free-form profile data: name, plan, signupDate). Persisted on the Crossdeck customer record under `customers/{cdcust}.traits`. Each `identify()` call merges additively — a later call with `{ plan: "pro" }` doesn't wipe a prior call's `{ name: "Wes" }`. Sanitised at the SDK boundary (functions dropped, Date / BigInt coerced, strings truncated, max 32 keys / 1 KB per value enforced server-side).
- **v0.10.0+** silently short-circuits to a no-op `AliasResult` when `Crossdeck.consent({ analytics: false })` is active — no network call fires.

### `Crossdeck.register(properties): Record<string, unknown>` *(v0.9.0+)*

Synchronous. Register super-properties — Mixpanel pattern. Every subsequent event automatically carries these keys on its `properties` bag.

```ts
Crossdeck.register({ plan: "pro", releaseChannel: "beta" });
Crossdeck.track("paywall_shown");   // properties include plan + releaseChannel
```

- Values that are `null` are deleted (Mixpanel's explicit "stop tracking this key" idiom).
- Values that are `undefined` are silently ignored (no-op).
- Caller-supplied properties on `track()` override super-properties on key collision — developer-supplied data is more authoritative.
- Sanitised through `validateEventProperties` (same path as `track()`).
- Cleared on `Crossdeck.reset()` / `Crossdeck.forget()` (identity-scoped).

Companion methods: `Crossdeck.unregister(key)` removes a single key; `Crossdeck.getSuperProperties()` returns a defensive copy of the current bag.

### `Crossdeck.group(type, id, traits?): void` *(v0.9.0+)*

Synchronous. Associate the current user with a group (org, team, account, plan). Mixpanel / Segment "Group Analytics" pattern — required for B2B SaaS dashboards.

```ts
Crossdeck.group("org", "acme_inc");
Crossdeck.group("team", "design", { headcount: 12 });
```

Once set, every event carries `$groups.<type>: id` on its `properties` bag. Pass `id: null` to clear a group membership. Multiple types coexist (`org` + `team` + `plan`). `Crossdeck.getGroups()` returns a snapshot keyed by type.

### `Crossdeck.consent(state): ConsentState` *(v0.10.0+)*

Synchronous. Update one or more consent dimensions. Three independent dimensions, each defaulting to `true` (granted):

- `analytics` — gates `track()` + `identify()` + auto-emissions (session.*, page.viewed, element.clicked).
- `marketing` — gates paid-traffic click IDs (`gclid`, `fbclid`, etc.) and the full referrer URL on events.
- `errors` — gates `webvitals.*` events and (future) error reporting.

```ts
Crossdeck.consent({ analytics: false });           // opt out of analytics
Crossdeck.consent({ marketing: true, errors: true }); // partial grant
```

DNT-derived denies (from `respectDnt: true` + browser DNT) cannot be flipped back on. `Crossdeck.consentStatus()` returns the current state.

### `Crossdeck.captureError(error, options?): void` *(v1.0.0+)*

Synchronous. Manually capture an error from a try/catch block.

```ts
try {
  await risky();
} catch (err) {
  Crossdeck.captureError(err, {
    context: { cart: { items: 3 } },
    tags: { flow: "checkout" },
    level: "error",   // "error" | "warning" | "info"
  });
}
```

The error ships through the same durable, retried, idempotent event queue as analytics. Stack frames are parsed (Chrome / Firefox / Safari formats), fingerprinted for grouping, and attached with the current breadcrumb buffer + any `setTag()` / `setContext()` state. Sends gate on `consent.errors`. Returns silently — never throws, even if the SDK isn't initialised yet.

### `Crossdeck.captureMessage(message, level?): void` *(v1.0.0+)*

Synchronous. Capture a non-error signal you want surfaced as an issue ("hit the deprecated path", "soft-warning code path"). Sentry pattern. Default level is `"info"`. Emits `error.message` events.

### `Crossdeck.setTag(key, value): void` / `setTags(tags): void` *(v1.0.0+)*

Synchronous. Attach a flat key/value label to every subsequent error report.

```ts
Crossdeck.setTag("release", "v2.3.1");
Crossdeck.setTags({ flow: "checkout", plan: "pro" });
```

Tags appear under `properties.tags` on every error event. Wiped on `reset()` / `forget()`.

### `Crossdeck.setContext(name, data): void` *(v1.0.0+)*

Synchronous. Attach a named blob of structured context to every subsequent error report.

```ts
Crossdeck.setContext("cart", { items: 3, total: 42.99 });
```

Unlike tags (flat strings), context can carry arbitrary JSON-serialisable data. Wiped on `reset()` / `forget()`.

### `Crossdeck.addBreadcrumb(crumb): void` *(v1.0.0+)*

Synchronous. Add a custom entry to the rolling breadcrumb buffer attached to every error report.

```ts
Crossdeck.addBreadcrumb({
  timestamp: Date.now(),
  category: "custom",
  message: "user-opened-paywall",
  data: { variant: "v3" },
});
```

The buffer caps at 50 entries; oldest evict. Auto-populated by `track()` calls, page views, and clicks — manual crumbs are for domain-meaningful moments not already captured.

### `Crossdeck.setErrorBeforeSend(hook): void` *(v1.0.0+)*

Synchronous. Install a pre-send filter for errors. Return null to drop, or a modified `CapturedError` to scrub fields.

```ts
Crossdeck.setErrorBeforeSend((err) => {
  if (err.message.includes("auth-token")) return null;
  err.context = { ...err.context, scrubbed: true };
  return err;
});
```

Sentry's `beforeSend` pattern — the only way to redact app-specific PII (auth tokens in URLs, etc.) before the report leaves the browser. The hook is called LAST, after rate-limit + sampling + URL gates already passed. A throwing hook falls back to the original error rather than dropping the report.

### `Crossdeck.forget(): Promise<void>` *(v0.10.0+)*

Async. GDPR / CCPA right-to-be-forgotten. Calls the backend's `/v1/identity/forget` endpoint to queue server-side deletion of the customer's events and profile, then wipes ALL local state (identity, entitlements, queue, super-props, persistent stores).

Idempotent. Safe to call when no identity exists (it just wipes the empty state). Server failure does NOT block local wipe — the user asked to be forgotten, declining their device wipe because the backend hiccupped would be the wrong call.

### `Crossdeck.getEntitlements(): Promise<PublicEntitlement[]>`

Async. Fetches the current customer's active entitlements from the server and warms the local cache so subsequent `isEntitled()` calls answer synchronously.

### `Crossdeck.isEntitled(key): boolean`

**Synchronous.** Reads from the local cache. Returns `false` until `getEntitlements()` (or a successful `syncPurchases()`) has populated the cache.

Pitfalls:
- **Never `await Crossdeck.isEntitled(...)`** — it's not a promise. The `await` works (resolves the boolean) but signals the wrong mental model in code review and AI-generated diffs.
- Safe to call inside SwiftUI `body { }`, Compose, or vanilla JS — it's a microsecond Set lookup.
- **In React, do NOT call `isEntitled` directly inside render.** The cache populates asynchronously after `init()`, and React has no way to know the cache changed — your component would show the empty-cache result forever. Use the **`useEntitlement(key)` hook from `@cross-deck/web/react`** (see Reactive Entitlements below) which subscribes the component to cache changes.

### `Crossdeck.listEntitlements(): PublicEntitlement[]`

Synchronous snapshot of the current cache. Same semantics as `isEntitled` — empty until `getEntitlements()` warms the cache. For React, the canonical wrapper is `useEntitlements()` from `@cross-deck/web/react`.

### `Crossdeck.onEntitlementsChange(listener): () => void`

**Synchronous.** Subscribe to entitlement-cache mutations. Returns an unsubscribe function (idempotent — calling twice is safe).

The listener fires AFTER each cache mutation:
- After `getEntitlements()` lands fresh data (warm-up or refresh)
- After `syncPurchases()` adds an entitlement
- On `reset()` (logout) — fires with the empty-cache state so UI bindings re-render to logged-out state

Listener errors are swallowed — a buggy consumer can't crash the SDK or other listeners. Listeners are NOT fired synchronously on subscribe — read state inline via `isEntitled()` / `listEntitlements()` if you need the initial value.

This is the foundation of the `useEntitlement` React hook. Other framework bindings (SwiftUI `@Observable`, Compose `State<Boolean>`, Vue `ref()`, Solid signals) use the same pattern.

### `Crossdeck.track(name, properties?): void`

Synchronous. Enqueues a telemetry event; the network round-trip happens in the background batched flush.

Pitfalls:
- The auto-tracker (default-on in browsers) ALREADY emits `page.viewed` on initial load + SPA navigation, plus `session.started` and `session.ended`. Snippets MUST NOT show developers manually calling `Crossdeck.track("Page.viewed")` or `Crossdeck.track("App.opened")` — that's a double-emit. Demo with a real domain event (e.g. `Crossdeck.track("paywall_viewed", { variant: "v3" })`).
- Properties are JSON-serialisable, ≤ 8 KB encoded. PII detection (email/password/token/etc) emits a debug warning when `debug: true`.
- **Property sanitisation (v0.8.0+).** Properties pass through `validateEventProperties()` at enqueue. Functions / symbols / undefined are dropped (debug warning). `Date` → ISO string, `BigInt` → string, `Error` → `{ name, message, stack }`, `Map` / `Set` → plain shapes. Strings > 1024 chars truncated with ellipsis. Circular refs replaced with `"[circular]"`, depth > 5 with `"[depth-exceeded]"`. Per-event property bag capped at 8 KB; past the cap, largest fields drop first and `__truncated: true` is added. Caller's input object is never mutated. Output is `JSON.stringify`-safe by contract — one bad property can no longer poison the batch.

### `Crossdeck.flush(): Promise<void>`

Async. Force-flush queued events. Useful before page unload. `flushEvents()` is a deprecated alias.

### `Crossdeck.syncPurchases(input): Promise<PurchaseResult>`

Async. Forward Apple StoreKit 2 evidence directly. Stripe + Google flows go via webhooks server-side — no SDK call needed. `purchaseApple()` is a deprecated alias.

### `Crossdeck.heartbeat(): Promise<HeartbeatResponse>`

Async. Manually send the boot heartbeat. Called automatically by `init()` unless `autoHeartbeat: false`.

### `Crossdeck.reset(): void`

Synchronous. Wipes persisted identity + entitlement cache + queued events. Call on logout. Next session mints a fresh `anonymousId`.

### `Crossdeck.setDebugMode(enabled): void`

Synchronous. Toggles the §16 debug-signal vocabulary at runtime.

### `Crossdeck.diagnostics(): Diagnostics`

Synchronous. Stable shape regardless of init state — pre-init returns sensible empties (`{ started: false, … }`).

---

## Reactive Entitlements (`@cross-deck/web/react`)

The web SDK ships a React subpackage at `@cross-deck/web/react` with first-class hooks that subscribe to the SDK's entitlements cache. Without these, every consumer would have to wire `useState` + `useEffect` + `onEntitlementsChange` themselves — and most would forget the cleanup function. The hooks own that plumbing.

```ts
import { useEntitlement, useEntitlements } from "@cross-deck/web/react";
```

| Hook                      | Returns          | Re-renders on                                    |
| ------------------------- | ---------------- | ------------------------------------------------ |
| `useEntitlement(key)`     | `boolean`        | Cache mutation that changes the entitlement key  |
| `useEntitlements()`       | `readonly string[]` | Any cache mutation                            |

**Canonical React snippet** (also produced by `_sdk-snippets.js`'s `webInstallSnippet`):

```jsx
import { Crossdeck } from "@cross-deck/web";
import { useEntitlement } from "@cross-deck/web/react";
import { useEffect } from "react";

// Initialize once in your top-level Provider:
useEffect(() => {
  Crossdeck.init({ appId, publicKey, environment });
  Crossdeck.getEntitlements();   // warm the cache
}, []);

// Then anywhere in the tree:
function ProBadge() {
  const isPro = useEntitlement("pro");
  return isPro ? <span className="badge">Pro</span> : null;
}
```

**SSR safety:** `useEffect` is a no-op on the server, and the hook's initial state is the cache's current value (defaults to `false` pre-init), so server output never claims a non-existent entitlement. Hydrates correctly on the client.

**Pre-init behaviour:** `useEntitlement` returns `false` if `Crossdeck.init()` hasn't been called yet. Once init runs and the cache mutates (e.g. via `getEntitlements()`), subscribed components re-render.

**Why this is the only correct pattern in React**: `Crossdeck.isEntitled(key)` is sync but the cache populates asynchronously. React doesn't know the cache changed → no re-render → component shows the empty-cache result forever. The hook ties cache state to React state via `onEntitlementsChange`, so React re-renders the moment the answer changes.

**Future framework bindings (NorthStar §11.4):** every SDK ships the same idea — SwiftUI `@Observable` wrapper for iOS, Compose `State<Boolean>` for Android, etc. — so the canonical install snippet stays one line per platform.

---

## Reactive Entitlements (`@cross-deck/web/vue`) *(v0.10.0+)*

Vue 3 composables mirroring the React subpackage. Same contract: subscribe a Vue component to the entitlement cache via `onEntitlementsChange`, so the component re-renders the moment the cache populates.

```ts
import { useEntitlement, useEntitlements } from "@cross-deck/web/vue";
```

| Composable             | Returns                  | Re-renders on                                    |
| ---------------------- | ------------------------ | ------------------------------------------------ |
| `useEntitlement(key)`  | `Ref<boolean>`           | Cache mutation that changes the entitlement key  |
| `useEntitlements()`    | `Ref<readonly string[]>` | Any cache mutation                               |

```vue
<script setup>
import { Crossdeck } from "@cross-deck/web";
import { useEntitlement } from "@cross-deck/web/vue";

Crossdeck.init({ appId, publicKey, environment });
Crossdeck.getEntitlements();   // warm the cache

const isPro = useEntitlement("pro");
</script>

<template>
  <span v-if="isPro" class="badge">Pro</span>
</template>
```

SSR safety: `onMounted` and `onScopeDispose` only run on the client; the initial Ref value is the cache's current state (`false` pre-init), so server output never claims a non-existent entitlement.

Vue is an optional peer dependency declared on the consumer side — non-Vue consumers don't pull it in by importing the core `@cross-deck/web`.

---

## Auto-tracking behaviour

When `init()` is called in a browser without `autoTrack: false`, the `AutoTracker` (`auto-track.ts`) installs and emits the following automatically:

| Event              | When                                                                     | Properties                                          |
| ------------------ | ------------------------------------------------------------------------ | --------------------------------------------------- |
| `session.started`  | On install + after >30 min idle resume                                   | `sessionId`                                         |
| `session.ended`    | On `pagehide` / `beforeunload` (or pre-resume); deduplicated per session | `sessionId`, `durationMs`                           |
| `page.viewed`      | On install + `pushState` / `replaceState` / `popstate`                   | `path`, `url`, `search`, `hash`, `title`, `referrer` |

Device info (os, browser, locale, screen, viewport, devicePixelRatio, optional `appVersion`) is auto-attached to every event when `autoTrack.deviceInfo !== false`.

**Per-session acquisition (v0.6.0+).** On every `session.started` the SDK reads `window.location.search` and `document.referrer`, captures the standard GA campaign params (`utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`) plus `referrer`, and attaches non-empty values to every subsequent event of that session. SPA route changes mid-session do NOT re-read the URL — first-touch attribution is session-pinned, matching GA4 semantics. A new session (>30 min idle, or explicit `resetSession()`) re-captures off the current URL.

**The implication for docs and snippets:** the only events developers should be shown calling are domain events specific to their product. `App.opened`, `Page.viewed`, and similar lifecycle events are auto-emitted; manual calls double-track and skew dashboards.

---

## Bank-grade plumbing (v0.8.0+)

Six closely-coupled hardenings shipped in 0.8.0. All additive — no callsite changes required, every new option has a sensible default. Together they bring the SDK's reliability surface up to Stripe / Segment / Mixpanel standards.

### Durable event queue

Queued events are written through to the SDK's identity store (typically `localStorage`) so a hard browser crash, power loss, or terminal-flush `keepalive: true` 64 KB cap exceedance 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.

Skipped when `persistIdentity: false` (strict-consent flows) — no point writing events to a store the developer told us not to use.

Implementation lives in `event-storage.ts` (`PersistentEventStore`).

### Exponential backoff with full jitter

Failed flushes used to retry on the next idle window — a flat-rate hot loop against a flapping endpoint. Now:

- Backoff: `min(maxMs, baseMs * 2^attempts)` ms, capped at 60 s.
- Full jitter: multiplied by `Math.random()` so 100 SDK instances retrying the same downed endpoint don't all hit at the same instant.
- Reset on success.
- Surface via `diagnostics().events.consecutiveFailures` and `nextRetryAt`.

Implementation lives in `retry-policy.ts` (`RetryPolicy`, `computeNextDelay`).

### Retry-After header support

The HTTP layer parses `Retry-After` (per RFC 7231 §7.1.3 — delta-seconds or HTTP-date) onto `CrossdeckError.retryAfterMs`, and the retry policy honours it when it exceeds the computed backoff. Stripe pattern — the server is the authority on its own pressure; ignoring its hint is what gets your IP blocked.

### Idempotency-Key per batch

Every `/v1/events` POST 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.

`HttpClient.request()` accepts a per-call `idempotencyKey` option for ad-hoc retried POSTs from any layer.

### Request timeout via `AbortController`

New `timeoutMs` option (default `15000`) on `CrossdeckOptions` and per-request override on `HttpClient.request()`. A captive portal or DNS hang would otherwise inherit the browser default (5+ minutes on Chrome) and lock the queue. Pass `timeoutMs: 0` to disable.

On expiry: `CrossdeckError({ type: "network_error", code: "request_timeout" })`.

### Property validation at enqueue

`track(name, properties)` sanitises `properties` BEFORE the event lands in the queue. See the `track()` Pitfalls section above for the full contract — the short version is one bad property (function, BigInt, circular ref, 100 KB blob) can never again poison the entire batch by crashing `JSON.stringify` at flush time.

### Diagnostics surface

`diagnostics()` now exposes the full Wave-1 surface:

```ts
{
  // ...existing fields...
  clock: { lastServerTime, lastClientTime, skewMs },
  entitlements: { count, lastUpdated, listenerErrors },
  events: { buffered, dropped, inFlight, lastFlushAt, lastError,
            consecutiveFailures, nextRetryAt },
}
```

- `clock.skewMs` is positive when the client clock is ahead of the server. Outside ±5 minutes is worth surfacing in the dashboard (kid changed the date, dev machine bad NTP).
- `entitlements.listenerErrors` counts swallowed throws from buggy subscribers without crashing the SDK or letting them go silent.
- `events.consecutiveFailures` + `nextRetryAt` let the dashboard render a real-time queue health card.

### Debug signals added

`sdk.property_coerced`, `sdk.queue_persisted`, `sdk.queue_restored`, `sdk.flush_retry_scheduled` — fire in debug mode only.

---

## Privacy + compliance (v0.10.0+)

### Consent

Three independent dimensions (`analytics`, `marketing`, `errors`), each gated by `Crossdeck.consent({...})`. Granular by design to match real consent banners ("Analytics" / "Marketing" / "Functional" checkboxes). Default state is "all granted" — call `consent({...: false})` before user-meaningful events fire to start in deny mode.

`webvitals.*` events gate on the `errors` dimension (treated as performance / reliability data), not analytics. Paid-traffic click IDs (`gclid` / `fbclid` / etc.) and the full referrer URL gate on `marketing`. Everything else gates on `analytics`.

### Do Not Track

Opt-in via `respectDnt: true` in `init()`. When enabled AND `navigator.doNotTrack === "1"` at init time, all three consent dimensions are locked OFF and cannot be flipped back on by subsequent `consent()` calls. Industry has effectively deprecated DNT but the SDK supports it for privacy-first apps.

### PII scrubbing

Enabled by default (`scrubPii: true`). Last step before an event leaves the queue: regex pass over every string property value, URL, title — replaces email-shaped substrings with `[email]` and card-number-shaped sequences with `[card]`. Server-side scrub-on-ingest is a Phase 2 follow-up (defence in depth).

### Right to be forgotten

`Crossdeck.forget()` calls `/v1/identity/forget` and wipes ALL local state. The backend marks the customer record `forgottenAt: now` and queues a `forgetRequests` row that the retention-cleanup worker drains. Stripe and other rail records stay — legal / accounting retention often supersedes GDPR for financial transactions, which the user agreed to at signup.

---

## Error capture *(v1.0.0+)*

The third Crossdeck USP. Reports every kind of browser-side failure through the same durable event queue analytics uses. Default-on — installing the SDK gets you analytics, revenue, AND errors in one line.

### Event names

| Wire name                    | Source                                                          |
| ---------------------------- | --------------------------------------------------------------- |
| `error.unhandled`            | `window.onerror` — synchronous uncaught error                   |
| `error.unhandledrejection`   | `window.onunhandledrejection` — async unhandled rejection       |
| `error.handled`              | `Crossdeck.captureError(err)` — manual try/catch capture        |
| `error.message`              | `Crossdeck.captureMessage(msg)` — non-error signals             |
| `error.http`                 | fetch/XHR returned 5xx OR threw a network failure               |

### Event property shape

Every error event carries the same property surface:

```ts
{
  fingerprint: string,      // 8-char hex, groups identical errors
  level: "error" | "warning" | "info",
  errorType: "TypeError" | "Error" | ... | null,
  message: string,          // ≤ 1024 chars
  stack: string,            // raw stack string, fallback for display
  frames: StackFrame[],     // parsed { function, filename, lineno, colno, in_app }
  filename: string,         // from window.onerror, when available
  lineno: number,           // 1-indexed
  colno: number,            // 1-indexed
  tags: { [k: string]: string },          // from setTag()
  context: { [k: string]: unknown },      // from setContext()
  breadcrumbs: Breadcrumb[],              // last ≤50 from the buffer
  http?: { url, method, status, statusText }   // only on error.http
  // …auto-attached enrichment (browser, os, sessionId, pageviewId, etc.)
}
```

### Defaults

- **Fingerprint grouping**: `djb2(message + top-3-in-app-frames)`. Stable across browsers. Backend may refine grouping further once source maps land.
- **Rate limit**: max 5 reports per fingerprint per minute. Defends against runaway loops in `setInterval` or render cycles.
- **Session cap**: 100 errors total per session. After that the SDK silently stops capturing until the next session. The dashboard sees "1 unique error" instead of a million events.
- **Noise filter**: built-in `ignoreErrors` strips `ResizeObserver loop`, `Script error.`, etc. Built-in `denyUrls` strips browser-extension frames (`chrome-extension://`, `moz-extension://`, etc.). Configurable per `init()`.
- **Self-skip**: `api.cross-deck.com` requests are explicitly excluded from `error.http` capture so a Crossdeck outage doesn't self-amplify back into the queue.
- **Crash safety**: every error-capture code path is wrapped in try/swallow. A bug in the SDK can NEVER take down the host app's error handler.

### Consent + privacy

- `consent.errors` gates every error event. `consent({ errors: false })` silently drops the lot.
- `scrubPii` runs on every error payload before flush — stack URLs, context blobs, message strings.
- `setErrorBeforeSend(hook)` is the only place to add app-specific PII redaction (auth tokens in URLs, etc.).

### Breadcrumb buffer

The "what was the user doing right before things broke" feature. Every `track()` call auto-emits a breadcrumb. The last 50 are attached to every error report. Categories: `navigation`, `ui.click`, `ui.input`, `http`, `console`, `custom`. Manual `Crossdeck.addBreadcrumb(...)` for domain-meaningful moments. Wiped on `reset()` / `forget()`.

---

## CDN build (UMD) *(v0.10.0+)*

`@cross-deck/web` ships a minified IIFE bundle at `dist/crossdeck.umd.min.js` (registered via the `unpkg` / `jsdelivr` fields). Exposes `window.Crossdeck` for no-build-step consumers (plain HTML, Webflow, Framer, docs pages):

```html
<script src="https://unpkg.com/@cross-deck/web@0.10.0/dist/crossdeck.umd.min.js"></script>
<script>
  window.Crossdeck.init({ appId: "...", publicKey: "cd_pub_live_..." });
</script>
```

React / Vue bindings are NOT included in the UMD bundle — CDN consumers are typically no-framework or pre-bundled apps. Size budget: 16 KB gzipped.

---

## Error codes (`@cross-deck/web/error-codes.json`) *(v0.10.0+)*

Every `CrossdeckError.code` the SDK can throw is documented in a machine-readable JSON sidecar at `dist/error-codes.json` and exported as `CROSSDECK_ERROR_CODES` from the main entry. Each entry carries `code`, `type`, `description`, `resolution`, and a `retryable` flag.

Used by:
- Crossdeck dashboard — to render human-friendly error tooltips.
- AI integration assistants — to suggest the right fix from a `code` alone.
- Error aggregators (Sentry, DataDog) — to group + annotate captured exceptions.

`getErrorCode(code)` is a typed lookup helper exported from the main entry.

---

## Bundle-size budgets

The build pipeline (`npm run build && npm run size`) fails the release if any artefact exceeds its gzipped budget. Current ceilings:

| Bundle                       | Budget (gz) |
| ---------------------------- | ----------- |
| `dist/index.mjs` / `.cjs`    | 28 KB       |
| `dist/react.mjs` / `.cjs`    | 28 KB       |
| `dist/vue.mjs` / `.cjs`      | 28 KB       |
| `dist/crossdeck.umd.min.js`  | 16 KB       |

For comparison: mixpanel-browser is ~55 KB gz, segment analytics.js is ~30 KB gz, posthog-js is ~40 KB gz. Crossdeck at ~25 KB gz is competitive. Consumers only pay for ONE entry point (core OR react OR vue), never multiple. Budgets are tightened with every release that lands without growing the surface area.

---

## Identity continuity — bank-grade redundancy (v0.6.0+)

The SDK persists `anonymousId` and `crossdeckCustomerId` so a returning user keeps the same Crossdeck identity across page loads. Single-store persistence is not enough: ITP, private browsing, "clear site data," and aggressive privacy extensions all wipe localStorage in real-world usage. When that happens, dashboards see one human as multiple visitors — a credibility hit on every chart that depends on uniqueness.

**Redundancy contract (browsers, default):**

- **Primary store**: `localStorage`
- **Secondary store**: 1st-party `document.cookie` set with `Path=/`, `Max-Age=63072000` (2y, clamped to 7 days by Safari ITP), `SameSite=Lax`, `Secure` (when over HTTPS — omitted on `http://localhost` so dev still works)
- **Read on boot**: take primary; fall back to secondary if primary is empty. If both differ (impossible in normal operation), primary wins because it's higher fidelity and the most-recently-written store
- **Write on every change**: write to BOTH stores so a future clear of either doesn't lose continuity. A throwing secondary (extension blocker, third-party cookie disable) does NOT crash primary writes — defence in depth
- **Reset on `Crossdeck.reset()`**: wipe BOTH stores, mint a fresh `anonymousId`, write that fresh value to BOTH

**Caveats — documented honestly:**

1. Safari ITP caps client-set 1st-party cookies at 7 days. Cookie redundancy protects against localStorage clears WITHIN that 7-day window, not beyond it. The full ITP-bypass story (server-set cookies via a customer-CNAMEd subdomain) is a Phase 2 follow-up that requires customer DNS configuration.
2. Cookie redundancy is automatic when the caller doesn't override `storage`. If a custom adapter is supplied, that wins and the cookie redundancy is the caller's responsibility — they chose a non-default store for a reason.
3. `persistIdentity: false` disables BOTH stores (in-memory only) so customers running strict consent flows can defer all writes until the user opts in.

**Privacy posture is unchanged from single-store identity** — we never write fingerprintable data, only the `anonymousId` already in localStorage. 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.

---

## Framework matrix (web SDK)

The onboarding + dashboard SDKs page asks each developer which framework they're using, and `_sdk-snippets.js:webInstallSnippet({ framework })` dispatches to one of seven generators. **Same SDK underneath — only the surrounding wrapper changes.** A green test pyramid for `@cross-deck/web@1.0.0` covers all of them, and every generator is locked in by `scripts/test-sdk-snippets.mjs` — a per-framework parse + assertion gate (`node --check` for JS/MJS, `tsc --noEmit --jsx react-jsx` for TSX, `tsc --noEmit` for TS, HTML tag-balance for the CDN snippet, plus must-contain / must-not-contain substring checks). Run with `npm run test:sdk-snippets`; CI fails on any regression.

| `framework` value     | Output shape                                                          | Where the snippet lands                                                                          |
| --------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `nextjs`              | React Provider (`"use client"`, `useEffect`, `useRef`)                | `app/providers.tsx` (App Router) or top-of-tree wrapper (Pages Router)                           |
| `react`               | Same as `nextjs` — alias                                              | `src/CrossdeckProvider.tsx` then wrap `<App />` in `src/main.tsx`                                |
| `vue`                 | `installCrossdeck(app, getUserId)` + Vue 3 composables                | `src/crossdeck.ts` + call from `main.ts` after `createApp()`                                     |
| `svelte`              | `startCrossdeck()` + `syncCrossdeckIdentity()` + `entitlement()` store | `src/lib/crossdeck.ts` + call from `+layout.svelte` `onMount`                                    |
| `vanilla-bundler`     | Direct `Crossdeck.init()` import — no framework wrapper               | `src/main.ts` entry file                                                                         |
| `plain-html`          | UMD CDN `<script>` tag with inline init                               | `<head>` of every HTML page (Webflow, Framer, plain HTML)                                        |
| `node`                | Init with `MemoryStorage` + `autoHeartbeat: false`                    | SSR layer, test fixtures, CLI scripts                                                            |

Legacy values (kept for backward compatibility): `plain-js` aliases to `plain-html`, `next.js` aliases to `nextjs`, `vanilla` aliases to `vanilla-bundler`.

Every snippet covers the same three behaviours: `init()`, identity mirroring (`identify` on auth, `reset` on logout), and a reference to entitlement gating. The React, Vue, and Svelte snippets use that framework's reactive primitives (`useEntitlement` hook, `Ref<boolean>`, `Readable<boolean>` store respectively) so components re-render when the entitlement cache mutates. The plain-HTML and vanilla snippets surface the raw `Crossdeck.isEntitled` API plus `Crossdeck.onEntitlementsChange` for manual DOM updates.

The `framework` field is persisted per app at `projects/{p}.apps[platform].framework` so fleet-level analytics can query "which frameworks are our users on" without round-tripping through the dashboard. Changes to the field write to `auditLog/` via the `onProjectWriteAuditChanges` Firestore trigger (eventType `project.framework_changed`) — tamper-evident.

Server-side, `framework` is also stamped onto every event at ingest (resolved from the API key in `backend/src/api/v1-auth.ts:ResolvedAppKey.framework`, plumbed through `backend/src/api/v1-events.ts:buildEventDoc`, and projected as a `LowCardinality(String)` column in ClickHouse migration `008_platform_framework.sql`). Dashboards can pivot real traffic by framework — "Vue customers' p95 LCP vs React customers'."

---

## Snippet style guide (canonical)

Every install snippet across the product MUST:
1. Import from `@cross-deck/web` (web), `Crossdeck` module (iOS), or `app.cross_deck.Crossdeck` (Android).
2. Call `Crossdeck.init({ appId, publicKey, environment })` — never `start()`, never `configure()` for web. iOS/Android use `Crossdeck.configure(...)` to match platform idiom (until those SDKs ship).
3. Wire `identify` to a real auth provider — never a hardcoded user ID string.
4. Show `isEntitled` SYNC — no `await`. Show real usage of the boolean (a JSX badge, a SwiftUI conditional, an `if` block) — never a dangling `const`. **For React snippets, always import `useEntitlement` from `@cross-deck/web/react`** — never call `Crossdeck.isEntitled` directly inside a render path (it doesn't trigger re-renders when the cache updates).
5. Skip `Crossdeck.track("Page.viewed")` / `"App.opened"` — those are auto-emitted.
6. Show a real domain event for `track()` if any (e.g. `paywall_viewed`, `Export.used`).

Both onboarding and the dashboard SDKs page MUST import from the canonical `_sdk-snippets.js` module at the repo root. Static HTML surfaces (homepage, docs) embed canonical content verbatim and are policed by `scripts/audit-sdk-snippets.mjs`.

---

## Banned patterns (audit script enforces)

These patterns must not appear OUTSIDE `_sdk-snippets.js` and `sdks/web/src/`:

| Pattern                                | Why                                                       |
| -------------------------------------- | --------------------------------------------------------- |
| `await Crossdeck.isEntitled`           | `isEntitled` is sync                                      |
| `Crossdeck.track("Page.viewed"`        | Auto-emitted by `auto-track.ts`                           |
| `Crossdeck.track("App.opened"`         | Will be auto-emitted; demo with a real domain event       |
| `Crossdeck.identify("user_123"`        | Hardcoded placeholder — wire to real auth                 |
| `Crossdeck.identify("user_<digits>"`   | Same — applies to any `user_NNN` pattern                  |
| `@crossdeck/web`                       | Wrong scope — npm scope is `@cross-deck` (hyphenated)     |
| `cd_pub_sandbox_`                      | SDK only recognises `cd_pub_test_` for sandbox             |
| `cd_pub_production_`                   | SDK only recognises `cd_pub_live_` for production         |
| `Crossdeck.start(`                     | Deprecated alias for `init()`                             |
| `await Crossdeck.<any-async-method>()` at column 0 | Top-level await is invalid in CJS / older bundlers and not portable across Next.js / Vite / CRA; wrap in a useEffect, async fn, or async IIFE |
| `Crossdeck.flushEvents(`               | Deprecated alias for `flush()`                            |
| `Crossdeck.purchaseApple(`             | Deprecated alias for `syncPurchases()`                    |

---

## Surfaces that mention the SDK

| Surface                                  | Source                                                           |
| ---------------------------------------- | ---------------------------------------------------------------- |
| Onboarding install step + AI prompts     | `onboarding/onboarding.js` `renderSnippets` (imports from `_sdk-snippets.js`) |
| Onboarding identify-users static HTML    | `onboarding/index.html` (kept verbatim with autogen marker)      |
| Dashboard SDKs page (install + AI)       | `dashboard/sdks/sdks.js` (imports from `_sdk-snippets.js`)       |
| Public docs landing                      | `docs/index.html`                                                |
| Public docs sub-pages                    | `docs/create-a-project/`, `docs/connect-stripe/`, `docs/api-keys/` |
| Homepage hero install                    | `index.html`                                                     |
| Web SDK README                           | `sdks/web/README.md`                                             |
| React subpackage source                  | `sdks/web/src/react.ts` (exported as `@cross-deck/web/react`)    |

The audit script (`scripts/audit-sdk-snippets.mjs`) walks every surface above and fails CI on any banned pattern.
