[
  {
    "sdk": "ios",
    "sdkLabel": "iOS app",
    "version": "1.0.0",
    "date": "2026-06-11",
    "change": "initial",
    "anchor": "ios-1.0.0",
    "install": null,
    "npm": null,
    "github": null,
    "markdown": "Initial release. The Crossdeck dashboard in your pocket — read-only by\ndesign: every number is read through the same backend the web dashboard\nuses, never recomputed on the phone.\n\n### Added\n\n- **Revenue** — the same revenue summary the web dashboard shows, at a\n  glance.\n- **Issues** — open issues sorted by paying-customer impact, with\n  Resolve / Ignore / Mute verdicts from the phone. Noise never renders.\n- **Live & Analytics** — who is on your product right now, and how the\n  recent days trended.\n- **Pulse** — the all-projects live world map.\n- **Push notifications** for the three moments that matter: an issue\n  touching paying customers, an error-rate spike, and the daily revenue\n  digest. Nothing else pushes.\n- Sign in with your existing Crossdeck account; switch projects from the\n  header."
  },
  {
    "sdk": "node",
    "sdkLabel": "Node",
    "version": "1.7.0",
    "date": "2026-06-11",
    "change": "minor",
    "anchor": "node-1.7.0",
    "install": "npm install @cross-deck/node@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/node",
    "github": "https://github.com/VistaApps-za/crossdeck-node/releases/tag/v1.7.0",
    "markdown": "**PARK on version-rejection — events are held, never dropped.** A third\nevent-queue outcome for the day the server stops accepting an outdated event\nformat. Purely additive; no public API change.\n\n**Added:**\n\n- **PARK (HTTP `426` / `sdk_version_unsupported`).** A version-rejection is now\n  recognised as its own outcome — distinct from retry (transient) and drop\n  (invalid): the data is good, only the wire dialect is stale. The queue\n  **holds** the events (folded to the buffer front, FIFO-capped at 1000),\n  **hushes** (stops flushing a known-too-old payload), **signals** once (one\n  `console.warn` + a typed `sdk.parked` debug event), and delivers on restart\n  after you upgrade. Node's queue is in-memory, so a process restart *before*\n  upgrade clears the held events — an opt-in disk-backed queue is on the\n  roadmap; the messaging says exactly this, never more.\n- **`sdk_version_unsupported`** added to the error-codes catalogue with\n  remediation, and `version_error` to `CrossdeckErrorType`. `CrossdeckError`\n  carries `minVersion` / `surface` from the 426 body. New `onParked` callback.\n\n**Fixed (no public API change):**\n\n- The empty-input contract is now codified cross-SDK as\n  `invalid-input-rejected-natively`: `track(\"\")` / `aliasIdentity` with a\n  missing `userId` reject at the call site by throwing a typed `CrossdeckError`\n  (`missing_event_name` / `missing_user_id`) and never reach the wire — the\n  Node/JS idiom of the invariant *\"invalid input never crashes the app.\"* No\n  behaviour change; the guarantee is now documented and bundled.\n- Standalone-build fix: the `contract-failed` schema-lock test now reads the\n  bundled contract (`_contracts-bundled.ts`) instead of the monorepo\n  `contracts/` path, so the published-mirror release build no longer fails.\n\nSee https://cross-deck.com/docs/sdk-event-durability/ for the durability contract."
  },
  {
    "sdk": "react-native",
    "sdkLabel": "React Native",
    "version": "1.7.0",
    "date": "2026-06-11",
    "change": "minor",
    "anchor": "react-native-1.7.0",
    "install": "npm install @cross-deck/react-native@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/react-native",
    "github": "https://github.com/VistaApps-za/crossdeck-react-native/releases/tag/v1.7.0",
    "markdown": "**PARK on version-rejection — events are held, never dropped.** A third\nevent-queue outcome for the day the server stops accepting an outdated event\nformat. Purely additive; no public API change.\n\n**Added:**\n\n- **PARK (HTTP `426` / `sdk_version_unsupported`).** A version-rejection is now\n  its own outcome — distinct from retry (transient) and drop (invalid): the data\n  is good, only the wire dialect is stale. The queue **holds** the events\n  (folded to the front of the durable AsyncStorage queue, FIFO-capped at 1000),\n  **hushes** (stops flushing a known-too-old payload — no wasted device\n  battery/bandwidth), **signals** once (one `console.warn` + a typed\n  `sdk.parked` debug event), and **backfills** on the next launch after you ship\n  an upgraded build. \"Paused, not lost — held on-device, resumes on upgrade.\"\n- **`version_error`** added to `CrossdeckErrorType`; `CrossdeckError` carries\n  `minVersion` / `surface` from the 426 body so the PARK message names the exact\n  version. New `onParked` callback.\n\n**Fixed (no public API change):**\n\n- The empty-input contract is now codified cross-SDK as\n  `invalid-input-rejected-natively` and proven with a dedicated test:\n  `track(\"\")` / `identify(\"\")` reject at the call site by throwing a typed\n  `CrossdeckError` (`missing_event_name` / `missing_user_id`) and never reach\n  the wire — the React Native idiom of the invariant *\"invalid input never\n  crashes the app.\"* No behaviour change; the guarantee is now documented and\n  tested.\n- Standalone-build fix: the `contract-failed` schema-lock test now reads the\n  bundled contract (`_contracts-bundled.ts`) instead of the monorepo\n  `contracts/` path, so the published-mirror release build no longer fails.\n\nSee https://cross-deck.com/docs/sdk-event-durability/ for the durability contract."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.7.1",
    "date": "2026-06-11",
    "change": "patch",
    "anchor": "swift-1.7.1",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.7.1",
    "markdown": "Docs-only patch — no code changes.\n\n- README brought current with the shipped reality: v1.7.0 status header (PARK\n  + first machine-tested release), PARK durability section under the Events\n  contracts, corrected install pins (1.7.x), per-language invalid-input\n  semantics, and the quickstart example's `CrossdeckEnvironment` type fix.\n  Cut as a release so SPM consumers (Xcode resolves the README at the version\n  tag) see current docs instead of the pre-1.7.0 snapshot."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.7.0",
    "date": "2026-06-11",
    "change": "minor",
    "anchor": "swift-1.7.0",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.7.0",
    "markdown": "**PARK on version-rejection — events are held, never dropped.** A third\nevent-queue outcome for the day the server stops accepting an outdated event\nformat. Purely additive; no public API change.\n\n**Added:**\n\n- **PARK (HTTP `426` / `sdk_version_unsupported`).** A version-rejection is now\n  its own `HTTPSendOutcome.Kind.parked`, distinct from `.permanent` (drop) and\n  `.retryable` (transient): the data is good, only the wire dialect is stale.\n  The queue **holds** the events (folded to the front of the on-disk queue,\n  capped at `maxBufferSize`), **hushes** (stops flushing a known-too-old\n  payload), **signals** once (one `print` line + a `sdk.parked` debug event),\n  and **backfills** on the next launch after upgrade. \"Paused, not lost — held\n  on-device, resumes on upgrade.\"\n- **`CrossdeckErrorType.versionError`** (`\"version_error\"`); `CrossdeckError`\n  carries `minVersion` / `surface` from the 426 body so the PARK message names\n  the exact version. New `onParked` queue handler.\n\n**Fixed (no public API change):**\n\n- **No public API can crash the host app on bad input.** `track(\"\")` and\n  `identify(\"\")` used `assertionFailure` (traps in Debug builds) and\n  `CrossdeckOptions(breadcrumbCapacity: 0)` used `precondition` (traps in\n  **Release** builds — the production app). All three are gone: empty input is\n  now dropped with a debug-log signal (`track_dropped` / `identify_dropped`),\n  and breadcrumb capacity is clamped to a safe minimum. This is the Swift idiom\n  of the cross-SDK invariant *\"invalid input is rejected at the call site\n  without crashing the app, and never reaches the wire\"* — Web/Node/RN signal\n  the same rejection by throwing a typed `CrossdeckError`; Swift drops + logs to\n  match its fire-and-forget surface (`identifyAndWait` remains the throwing\n  variant). Codified as the `invalid-input-rejected-natively` contract.\n- First Swift release verified by CI before publication — a new\n  `PublicAPIInputSafetyTests` suite proves every public fire-and-forget entry\n  point survives empty/garbage input in **both** Debug and Release\n  configuration.\n\nSee https://cross-deck.com/docs/sdk-event-durability/ for the durability contract."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "1.8.0",
    "date": "2026-06-11",
    "change": "minor",
    "anchor": "web-1.8.0",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v1.8.0",
    "markdown": "**PARK on version-rejection — events are held, never dropped.** A third\nevent-queue outcome for the day the server stops accepting an outdated event\nformat. Purely additive; no public API change.\n\n**Added:**\n\n- **PARK (HTTP `426` / `sdk_version_unsupported`).** Previously a permanent 4xx\n  dropped the batch. Now a version-rejection is recognised as its own outcome —\n  distinct from retry (transient) and drop (invalid): the data is good, only the\n  wire dialect is stale. The queue **holds** the events (folded to the front of\n  the durable localStorage queue, FIFO-capped at 1000), **hushes** (stops\n  flushing a known-too-old payload — no wasted battery/bandwidth), **signals**\n  once (one `console.warn` + a typed `sdk.parked` debug event the dashboard\n  reads), and **backfills** on the next page load after you upgrade. So \"paused,\n  not lost — held on-device, resumes on upgrade\" is literally true.\n- **`sdk_version_unsupported`** added to the error-codes catalogue with\n  remediation, and `version_error` to `CrossdeckErrorType`. `CrossdeckError`\n  now carries `minVersion` / `surface` (parsed from the 426 body) so the PARK\n  message names the exact version to update to.\n- New `onParked` queue callback (host → dashboard heartbeat channel).\n- Bundle-size budget: UMD min 33 → 35 KB gzipped (~0.5 KB for the PARK branch +\n  onParked + catalogue entry + minVersion/surface). core/react/vue unchanged,\n  under budget.\n\nSee https://cross-deck.com/docs/sdk-event-durability/ for the full durability\ncontract."
  },
  {
    "sdk": "node",
    "sdkLabel": "Node",
    "version": "1.6.0",
    "date": "2026-06-10",
    "change": "minor",
    "anchor": "node-1.6.0",
    "install": "npm install @cross-deck/node@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/node",
    "github": "https://github.com/VistaApps-za/crossdeck-node/releases/tag/v1.6.0",
    "markdown": "Event Envelope v1 conformance — server-enforced contract (spec\n`backend/docs/event-envelope-spec-v1.md`).\n\n**Added:**\n\n- **`envelopeVersion: 1`** (integer) on every batch POST body. Both the\n  queue-flush path (`EventQueue.flush()`) and the direct `ingest()` path\n  now emit this field. The server will reject payloads missing this field\n  once ingest enforcement lands.\n- **`seq`** (number) on every wire event — per-session monotonic sequence\n  number. Captured synchronously with the event's `timestamp` at\n  `track()` / enqueue time. Counter starts at 0 when the `CrossdeckServer`\n  instance is constructed (session start) and increments once per event.\n  Matches spec §3: monotonic within a session, never reset between\n  background/foreground (Node has no such lifecycle; the instance lifetime\n  IS the session).\n- **`context`** (object) on every wire event — standardized device/platform\n  context (spec §4), promoted out of `properties`. Common fields: `os`,\n  `osVersion`, `appVersion`, `sdkName`, `sdkVersion`, `locale`, `timezone`.\n  Node-specific: `nodeVersion`, `host`, `region` (the existing\n  `runtime.*` props, promoted).\n\n**Changed:**\n\n- `track()` no longer merges `runtime.*` keys into `properties`. Those\n  facts now live in the top-level `context` object on the wire event.\n  Super-properties registered via `server.register()` continue to appear\n  in `properties` unchanged (caller-supplied values are unaffected)."
  },
  {
    "sdk": "react-native",
    "sdkLabel": "React Native",
    "version": "1.6.0",
    "date": "2026-06-10",
    "change": "minor",
    "anchor": "react-native-1.6.0",
    "install": "npm install @cross-deck/react-native@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/react-native",
    "github": "https://github.com/VistaApps-za/crossdeck-react-native/releases/tag/v1.6.0",
    "markdown": "Event Envelope v1 conformance — all three shared gaps from the Phase-0\naudit matrix (Q2 sequence, Q3 context drift, Q4 envelope version) are\naddressed in this release.\n\n**Added:**\n\n- **`envelopeVersion: 1`** on the batch envelope (POST body). Integer\n  schema version required by the server's reject-unversioned rule\n  (spec §6.1). Distinct from `sdk.version` — answers the ingest\n  parsing question, not Version Health.\n- **`seq` on every event** (spec §3). Per-session monotonic integer,\n  reset to 0 at `setSessionId()` (session boundary), incremented\n  synchronously at `track()` alongside `timestamp`. Persists across\n  app background/foreground within the same session per the spec's\n  non-negotiable §3 clauses.\n- **`context` object on every event** (spec §4). Standardised\n  device/platform fields promoted out of `properties`: `os`,\n  `osVersion`, `appVersion`, `sdkName`, `sdkVersion`, `locale`,\n  `timezone`, `deviceModel` (RN-specific; uses `Platform.constants.Model`\n  falling back to `Brand`).\n\n**Changed (breaking wire format):**\n\n- Device info fields (`os`, `osVersion`, `model`, `brand`, etc.)\n  are no longer spread into event `properties`. They live exclusively\n  in the top-level `context` object. App-supplied caller properties\n  remain in `properties` as before."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.6.0",
    "date": "2026-06-10",
    "change": "minor",
    "anchor": "swift-1.6.0",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.6.0",
    "markdown": "**Event Envelope v1 conformance.** The Swift SDK now emits the\nserver-owned Event Envelope v1 contract\n(`backend/docs/event-envelope-spec-v1.md`), closing the three Phase-0\naudit gaps for Apple. Purely additive on the wire — the v1 server\nignores unknown fields, so this rolls out ahead of, or behind, the\nother SDKs without coordination. No public API changes.\n\n**Added:**\n\n- **`envelopeVersion: 1`** on every batch (§1). The integer schema/wire\n  version the server parses against — *\"can I parse this?\"* — kept\n  strictly distinct from `sdk.version` (*\"which build is in the\n  wild?\"*). Two questions, two fields.\n- **Per-session `seq`** on every event (§3). A non-negative integer,\n  monotonic within a session, reset to 0 at `session.started`, assigned\n  synchronously at `track()` time from a session-scoped counter owned by\n  the AutoTracker (the single owner of session state). It persists\n  across app background/foreground within a session — backgrounding\n  does NOT reset it, so a delayed flush that batches a pre-background and\n  a short-resume event never emits a duplicate seq. An ambiguous session\n  boundary (idle resume past threshold, crash recovery) mints a NEW\n  session, which resets `seq` — `seq` integrity beats session\n  continuity. `seq` round-trips through the on-disk persistence layer.\n- **Standardized top-level `context` object** (§4) promoted out of\n  `event.properties`: `os`, `osVersion`, `appVersion`, `sdkName`,\n  `sdkVersion`, `locale`, `timezone`. App-supplied super-properties and\n  event properties stay in `properties` as before — only the\n  device/platform facts move.\n- **`context.deviceModel`** — the hardware model identifier\n  (`iPhone15,2`, `Mac15,3`, …) read from `utsname.machine` (with the\n  Simulator's `SIMULATOR_MODEL_IDENTIFIER` env override). Swift\n  previously omitted device model on purpose; the v1 context schema\n  requires it from Apple platforms, so it is now collected. Omitted from\n  the wire when undetectable.\n- **`$error` events are conformant too.** Captured errors now carry a\n  real per-session `seq` (drawn from the same counter as `track()`, not\n  the default 0 that would collide with `session.started`) and the same\n  standardized `context`. `track()` and the crash path share one\n  context source (`DeviceInfo.eventContext`) so they cannot drift —\n  spec §2 makes both fields required on *every* event, not just\n  analytics ones."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.5.6",
    "date": "2026-06-10",
    "change": "patch",
    "anchor": "swift-1.5.6",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.5.6",
    "markdown": "**Existing Apple subscribers now self-heal even in apps that never call\n`identify()`.** The 1.5.3 launch sweep of `Transaction.currentEntitlements`\nonly fired when a `developerUserId` was already persisted, so an\nanonymous-only app (no login, every user anonymous for the life of the\ninstall) never forwarded its existing Apple subscribers to Crossdeck — the\ndashboard showed no revenue while the entitlement still worked on-device.\nDiscovery does not need a named owner: each verified transaction is forwarded\nwith the install-stable `appAccountToken`, so the server attributes the\nsubscription to the install and re-attributes it to the user automatically on\nthe first `identify()` that carries the token.\n\n**Changed:**\n\n- The launch-time `currentEntitlements` sweep now runs on **every launch,\n  identified or not** (gated only by `automaticAppleEntitlementSync`, still on\n  by default). The per-session dedupe key is the `developerUserId` when\n  identified, else the always-present `anonymousId`, so an\n  anonymous→identified transition re-sweeps exactly once and a repeated\n  same-id `identify()` does not.\n- The family-shared skip (1.5.5) is unchanged — a family-shared transaction is\n  still never attributed.\n\nNo public API changes; access behaviour is unchanged."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "1.7.0",
    "date": "2026-06-10",
    "change": "minor",
    "anchor": "web-1.7.0",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v1.7.0",
    "markdown": "Event Envelope v1 conformance (`backend/docs/event-envelope-spec-v1.md`). Wire-breaking change (pre-launch; free). No public API change.\n\n- **`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.\n- **`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.\n- **`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.\n- 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."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.5.5",
    "date": "2026-06-09",
    "change": "patch",
    "anchor": "swift-1.5.5",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.5.5",
    "markdown": "**Matching discipline: never attribute a family-shared subscription.** The\n1.5.3 currentEntitlements sweep forwarded *every* verified entitlement,\nincluding family-shared ones — but a family-shared transaction's\n`originalTransactionId` belongs to the family ORGANIZER, not the signed-in\nuser. Binding it (with this user's `appAccountToken` attached) would hand the\norganizer's subscription to a family member: a wrong-merge, the one outcome\npillar #1 forbids.\n\n**Fixed:**\n\n- The auto-sweep now SKIPS transactions whose `ownershipType == .familyShared`\n  for ATTRIBUTION. Access is unaffected — the family member is still entitled\n  locally (the Phase 3 gate honours family-shared receipts); only the\n  owner-label binding is withheld. The organizer attributes the subscription on\n  their own device, where the transaction is `.purchased`. The\n  `apple.entitlements_resynced` summary event gains a `skipped_family_shared`\n  count for observability."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.5.4",
    "date": "2026-06-09",
    "change": "patch",
    "anchor": "swift-1.5.4",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.5.4",
    "markdown": "**Access never waits for attribution.** A paying subscriber is now entitled by\ntheir own device's signed Apple receipt the instant they open the app —\noffline, before any backend round-trip — instead of waiting on the backend\nentitlement cache. This is the ACCESS half of the Apple spine (1.5.3 shipped\nthe attribution half).\n\n**Added:**\n\n- **`entitlementStatus(_:) -> EntitlementStatus` — a three-state gate.**\n  `.entitled` / `.notEntitled` / `.resolving`. Prefer it over `isEntitled` for\n  paywalls: `.resolving` (\"checking your subscription…\") is the honest answer\n  when the device holds a verified active subscription the SDK can't yet name\n  (fresh install, never synced online, fully offline). Showing a spinner there\n  instead of a paywall is what stops a flash of \"not Pro\" at a paying user.\n  Self-heals on first connectivity. (That sliver — never online, then offline —\n  is irreducible; RevenueCat carries the identical one.)\n\n**Changed:**\n\n- **`isEntitled(_:)` now resolves device-first.** Resolution order: a backend\n  grant (persisted, offline-hydrated) wins; else a device-verified Apple\n  receipt mapped to the key wins — **offline, and never revoked against an\n  *absent* backend** (Apple's signature is proof of payment); else `.resolving`\n  or `.notEntitled`. **Monotonic + back-compatible:** returns `true` for every\n  case the old cache-only check did, plus device-verified receipts the cache\n  hasn't caught up to. It never flips a genuinely entitled user to `false`.\n\n  Reconciliation rule: **protect against backend-absent; yield to\n  backend-present-and-disagrees.** The verified receipt is sacrosanct when the\n  network is down, but the product→entitlement MAP is developer config — a\n  reachable backend that re-sources a mapping wins (the map is rebuilt\n  last-snapshot-wins from the freshest backend snapshot, so a retired\n  product→key pairing can't linger on-device).\n\nNon-iOS / pre-iOS-15 targets are unaffected — resolution stays backend-only,\nexactly as before."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.5.3",
    "date": "2026-06-09",
    "change": "patch",
    "anchor": "swift-1.5.3",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.5.3",
    "markdown": "**Existing Apple subscribers now self-heal on return — no migration script.**\nThe user↔subscription link for Apple lives per-purchase on the device, not in\nyour backend, so there was never anything to bulk-import; ownership has to be\nbackfilled as each subscriber returns. `PurchaseAutoTrack` watches\n`Transaction.updates`, but that only fires for NEW/changed transactions — a\nsubscriber who bought before this build never linked until their next renewal.\n\n**Added:**\n\n- **`automaticAppleEntitlementSync` (default ON, iOS 15+).** On launch (for an\n  already-identified user) and immediately after `identify()`, the SDK sweeps\n  `Transaction.currentEntitlements` once and forwards each verified\n  subscription's signed JWS to the same `/purchases/sync` endpoint, binding\n  `originalTransactionId → developerUserId`. The existing base self-heals the\n  first time each subscriber opens the migrated build — no backend script, no\n  \"Restore Purchases\" tap.\n\n  This is **attribution, not access** — it only puts an owner label on a paid\n  subscription. It is deliberately silent (no per-sub `purchase.completed`\n  funnel events; one `apple.entitlements_resynced` summary event instead),\n  deduped per developerUserId per session, and idempotent (the backend derives\n  a deterministic key from the JWS), so a repeated sweep never double-counts.\n  A silent no-op for apps without Apple IAP.\n\n  Opt out with `CrossdeckOptions(automaticAppleEntitlementSync: false)`. The\n  manual path (`syncPurchases`) and the `Transaction.updates` auto-listener\n  (`automaticPurchaseTracking`) are unchanged; all three now share one\n  `/purchases/sync` closure so the wire contract never drifts."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.5.2",
    "date": "2026-06-09",
    "change": "patch",
    "anchor": "swift-1.5.2",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.5.2",
    "markdown": "**Compile-fix patch on 1.5.1.** Every documented call-site — the README,\nthese release notes, and all of the public docs — writes the helper as\n`Crossdeck.appAccountTokenForCurrentIdentity()` (static syntax), but 1.5.1\ndeclared it `public func` (instance only). Pasting the documented snippet\nproduced `Instance member 'appAccountTokenForCurrentIdentity' cannot be used\non type 'Crossdeck'` on first build. Caught by a dogfood integrator.\n\n**Added:**\n\n- **Static overload `Crossdeck.appAccountTokenForCurrentIdentity() -> UUID`.**\n  Purely additive — Swift resolves the type-level call to the new static\n  form and `client.appAccountTokenForCurrentIdentity()` to the original\n  instance method, so both coexist and no existing caller breaks. The\n  static form delegates to the live client (`Crossdeck.current`), minting\n  and persisting through the started SDK's identity store exactly as the\n  instance method does. Before `start()` there is no store to own a\n  persisted token, so it returns a fresh anonymous `UUID` to honour the\n  non-optional \"never nil\" contract; the first call after `start()` mints\n  the install-stable token.\n\n  ```swift\n  let token: UUID = Crossdeck.appAccountTokenForCurrentIdentity()\n  let result = try await product.purchase(options: [\n      .appAccountToken(token)\n  ])\n  ```"
  },
  {
    "sdk": "platform",
    "sdkLabel": "Platform",
    "version": "1.0.0",
    "date": "2026-06-03",
    "change": "initial",
    "anchor": "platform-1.0.0",
    "install": null,
    "npm": null,
    "github": null,
    "markdown": "- Initial release."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "1.6.3",
    "date": "2026-06-01",
    "change": "patch",
    "anchor": "web-1.6.3",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v1.6.3",
    "markdown": "Patch — error-capture noise reduction. The fetch wrapper raised an\n`HTTPError(status 0)` for **every** failed request, including the opaque\n`TypeError` an adblocker, an offline device, or an aborted navigation\nproduces. Per the Fetch spec these are indistinguishable from a real outage,\nso `status 0` is the single biggest false-alarm source in browser error\ntracking — a developer's own adblocker blocking a first-party asset would\npage as a production error. No public API change.\n\n**Status-0 network failures from unambiguous client noise are no longer\nreported.** A new internal predicate suppresses three cases: `AbortError`\n(navigation / explicit cancel), offline (`navigator.onLine === false`), and\n**same-origin** failures — the page itself loaded from that origin, so it's\ndemonstrably reachable; a status-0 there is client-side blocking, not a\nserver fault. Genuine same-origin server failures still surface as 5xx on\nthe success path. **Cross-origin outages still report** — real signal is\nkept, only the noise is dropped. The request breadcrumb still fires\nregardless, so context is preserved on other errors."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "1.6.2",
    "date": "2026-05-31",
    "change": "patch",
    "anchor": "web-1.6.2",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v1.6.2",
    "markdown": "Session-boundary correctness, plus the per-platform contract runtime status\nthat had been sitting unreleased. No public API change; `session.ended`\nemission timing changes as described, so read before upgrading if you key\ndownstream logic on it.\n\n**A tab left open and idle, then used again, no longer stretches one session\nacross the gap.** `markActivity()` now rolls to a new session when an event\nlands after the 30-minute inactivity window has lapsed — covering the case the\npage-load and tab-return resume checks miss entirely (a tab kept open and idle,\nthen interacted with, with no visibility transition). A single stored session\ncan no longer contain a >30-minute gap.\n\n**Returning to a long-idle tab no longer back-dates `session.ended`.** The\nvisibility-resume path emitted `session.ended` at the moment of return — more\nthan 30 minutes after the session's last real event — which itself opened an\nintra-session gap. The prior session now ends implicitly (its end inferred from\nits last event), consistent with the page-load resume path. If you key\ndownstream logic on `session.ended`, note it no longer fires on a >30-minute\ntab-return.\n\n**Per-platform contract runtime status + a 7th live verifier.** Each bundled\n\n**Per-platform contract runtime status + a 7th live verifier.** Each bundled\ncontract now carries `runtimeVerified` — whether *this* SDK self-verifies it\nat runtime vs. proving it in CI only. It is derived at build time from the\nSDK's `STATIC_VERIFIERS` registry (never hand-set), so the registry can't\ndisagree with what actually runs. `CrossdeckContracts` consumers can read it\nto distinguish \"watch it pass live\" from \"CI-proven every release\".\n\n- New runtime verifier `sdk-error-codes-catalogue` (boot self-test: every\n  backend wire code carries a description + resolution in the shipped\n  catalogue). Web now self-verifies **7** contracts live.\n- Bundle-size budget: UMD min 32 → 33 KB (~0.4 KB gzipped for the flag + the\n  new verifier's frozen 15-code list). Other bundles unchanged, under budget."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "1.6.0",
    "date": "2026-05-30",
    "change": "minor",
    "anchor": "web-1.6.0",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v1.6.0",
    "markdown": "Minor — two autocapture fidelity fixes. No public API change; event\nemission behaviour changes as described, so read before upgrading if you\nhave downstream logic keyed on `session.ended` timing.\n\n**Sessions now survive full-page navigations.** Session state was\nin-memory only, so on a multi-page site (where the SDK re-installs on\nevery navigation) one visit was split into a separate session per page —\neach `session.ended` on `pagehide` landing at the same instant the next\npage's `session.started` fired. Sessions are now persisted (id, start,\nlast-activity, first-touch acquisition) to the same storage adapter as\nidentity and **resumed** across page loads within a rolling 30-minute\ninactivity window.\n\n- `session.started` no longer fires on a resumed page load — only on a\n  genuinely new session (first visit, or first load after >30 min idle).\n- `session.ended` no longer fires on `pagehide` / `beforeunload` (a\n  navigation is not a session end). It fires only on real 30-min\n  inactivity or an explicit `Crossdeck.stop()`.\n- The inactivity window is bumped by every tracked event (auto or\n  custom), not just pageviews/clicks.\n- Honours consent posture: with `persistIdentity: false` or a\n  `MemoryStorage` adapter, the session is in-memory only (per-page, the\n  prior behaviour).\n- Continuity is same-origin (localStorage); cross-subdomain stitching is\n  not yet handled.\n\n**Click autocapture no longer mashes labels.** A click on a control that\nwraps other controls or a content block (a card around several buttons, a\nhero `<a>` around a heading + paragraph) used to collapse the whole\nsubtree into one string — `\"Log inContinue with GoogleContinue with\nApple…\"`, `\"Tudo que você é,em um só link.Portfolio…\"`. The resolver now\nreturns the control's own label (word boundaries preserved, decorative\n`svg`/`style`/`script` skipped), or for a wrapper its direct label / the\nfirst heading inside it, falling through to the selector rather than a\nconcatenation. Attribute precedence (`data-*` → aria → value → text →\ntitle → img/svg) is unchanged.\n\nBundle-size budget for the core bundles raised 55 → 58 KB gzipped (ESM +\nCJS) to fit the ~1 KB of new code; react/vue/UMD bundles unchanged and\nstill under budget."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.5.1",
    "date": "2026-05-29",
    "change": "patch",
    "anchor": "swift-1.5.1",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.5.1",
    "markdown": "**Compile-fix patch on 1.5.0.** The 1.5.0 helper signature didn't\nmatch StoreKit's contract — `Product.PurchaseOption.appAccountToken`\ntakes a `UUID`, not a `String`, so the snippet in the 1.5.0 release\nnotes would not compile when pasted as-is. Caught by a dogfood\nintegrator before 1.5.0 reached anyone else. No behavioural change\nbeyond the type — the persisted storage representation is unchanged.\n\n**Changed:**\n\n- **`Crossdeck.appAccountTokenForCurrentIdentity()` now returns\n  `UUID` instead of `String`.** The helper still mints a single\n  RFC 4122 random UUID v4 per install, persists it under\n  `crossdeck.apple_app_account_token`, and returns the same value\n  forever until `reset()`. The string form is what crosses the\n  wire to the backend on `identify()`; the `UUID` form is what\n  StoreKit demands at purchase time. Source-incompatible only if\n  you were storing the return value in a `String` — replace with\n  `UUID` and the rest of the call site is unchanged.\n\n  Corrected snippet:\n\n  ```swift\n  let token: UUID = Crossdeck.appAccountTokenForCurrentIdentity()\n  let result = try await product.purchase(options: [\n      .appAccountToken(token)\n  ])\n  ```\n\n- **Doc-comment expanded to cover the anonymous-user / pre-identify\n  case.** Three properties are now spelled out on the helper itself\n  so integrators don't have to second-guess from release notes:\n\n  1. **Never returns `nil`.** Even before the first `identify()`\n     call, the helper lazy-mints a UUID and returns it. Purchases\n     made while the SDK only knows the anonymous identity still get\n     a stable token.\n  2. **Not derived from `anonymousId`.** It is a fresh random UUID,\n     independent of every other Crossdeck identifier. Rotating or\n     resetting `anonymousId` does not affect it.\n  3. **Persists across `identify()`.** When the developer later\n     calls `identify(\"user_123\")`, the SDK forwards the existing\n     token alongside the alias request so the backend can bind\n     `appAccountToken → developerUserId` without minting a new one.\n     Past purchases stay attributed to the same chain.\n\n  `reset()` is the only thing that wipes it, by design — the next\n  user on the same device must not inherit the prior user's token."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.5.0",
    "date": "2026-05-29",
    "change": "minor",
    "anchor": "swift-1.5.0",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.5.0",
    "markdown": "**Bank-grade Apple-rail Shape 2 fix.** The previous v1.4.x line\nshipped an `appAccountToken` derivation that was a deterministic\nfunction of `developerUserId`. That input is mutable across a\nuser's life (anonymous → logged in → account merge → SSO upgrade),\nand Apple's transaction records are permanent. The instant\n`developerUserId` changed, the SDK could no longer reproduce the\ntoken Apple had stored — every renewal in that chain orphaned\nsilently. Bug count grew linearly with auto-tracked purchases.\n\nThis release makes the bug **impossible by construction** on the\nhappy path. The full design rationale is in\n`Sources/Crossdeck/Identity.swift`'s `reset()` doc-comment.\n\n**Added:**\n\n- **`Crossdeck.appAccountTokenForCurrentIdentity() -> String`** —\n  public helper. First call mints a fresh `UUID()`, persists it\n  under the storage key `crossdeck.apple_app_account_token`, and\n  returns the same value forever — independent of any `identify()`\n  mutation. Wiped only on `reset()` (sign-out) so the next user on\n  the same device receives a fresh token.\n\n  Pass the result to StoreKit at purchase time:\n\n  ```swift\n  let token = Crossdeck.appAccountTokenForCurrentIdentity()\n  let result = try await product.purchase(options: [\n      .appAccountToken(token)\n  ])\n  ```\n\n  Server-side, the binding `appAccountToken → developerUserId` is\n  recorded via `identify()`'s alias request (the SDK attaches the\n  persisted token automatically). Apple's later ASSN V2 webhook\n  resolves via that binding, not the older implicit assumption\n  that `appAccountToken == developerUserId`.\n\n**Changed:**\n\n- `PurchaseAutoTrack` and `Crossdeck.syncPurchases(...)` now read\n  the persisted token via `Identity.ensureAppAccountTokenSync()`\n  instead of deriving from `developerUserId`. Existing call sites\n  upgrade automatically — no source changes required for consumers\n  using the auto-track path.\n- `AliasIdentityRequest` gains `appAccountToken: String?`. Every\n  `identify()` and `identifyAndWait()` now carries the persisted\n  token (when present) so the server records the binding before\n  any later webhook arrives.\n\n**Deprecated:**\n\n- `AppAccountTokenDerivation.derive(developerUserId:)` — kept in\n  the module to preserve the pinned cross-SDK oracle, but no\n  longer called from the auto-track or `syncPurchases` paths.\n  Header comment documents the failure walk so future contributors\n  don't reintroduce the same trap by \"simplifying\" the persistence\n  away.\n\n**The actionability principle:**\n\nCrossdeck's existing design philosophy (\"classify, don't silently\ndrop\") is right for events the developer can act on — cross-origin\n`Script error.` events get captured with a `cross_origin` tag and\npointed at the CORS fix. It is NOT right for events the developer\ncannot act on. Browser-extension errors, ad-blocker hits, and\nShape 2 orphans created by the prior derivation path all fall in\nthe latter category. This release applies the same principle to\nthe Apple-rail token: drop the deterministic-from-userId\nderivation, persist a stable per-install UUID, and let the server\nown the resolution via the recorded binding."
  },
  {
    "sdk": "node",
    "sdkLabel": "Node",
    "version": "1.5.1",
    "date": "2026-05-27",
    "change": "patch",
    "anchor": "node-1.5.1",
    "install": "npm install @cross-deck/node@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/node",
    "github": "https://github.com/VistaApps-za/crossdeck-node/releases/tag/v1.5.1",
    "markdown": "`crossdeck.contract_failed` is now single-fire to a dedicated\nreliability endpoint instead of the customer's `track()` pipeline.\nIndependent-controller flow per Privacy Policy §6; schema-locked by\n`contracts/diagnostics/contract-failed-payload-schema-lock.json`.\n`ContractFailureInput.extra` removed (schema-lock forbids unbounded\nfields); `ContractFailureInput.deviceClass` added."
  },
  {
    "sdk": "react-native",
    "sdkLabel": "React Native",
    "version": "1.5.1",
    "date": "2026-05-27",
    "change": "patch",
    "anchor": "react-native-1.5.1",
    "install": "npm install @cross-deck/react-native@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/react-native",
    "github": "https://github.com/VistaApps-za/crossdeck-react-native/releases/tag/v1.5.1",
    "markdown": "`crossdeck.contract_failed` is now single-fire to a dedicated\nreliability endpoint instead of the customer's `track()` pipeline.\nIndependent-controller flow per Privacy Policy §6; schema-locked by\n`contracts/diagnostics/contract-failed-payload-schema-lock.json`.\n`ContractFailureInput.extra` removed (schema-lock forbids unbounded\nfields); `ContractFailureInput.deviceClass` added."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.4.10",
    "date": "2026-05-27",
    "change": "patch",
    "anchor": "swift-1.4.10",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.4.10",
    "markdown": "`Crossdeck.reportContractFailure(_:)` is now single-fire to a\ndedicated reliability endpoint instead of the customer's `track(_:)`\npipeline. Independent-controller flow per Privacy Policy §6;\nschema-locked by `contracts/diagnostics/contract-failed-payload-\nschema-lock.json`. `ContractFailureInput.extra` removed (schema-lock\nforbids unbounded fields); `ContractFailureInput.deviceClass` added."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.4.9",
    "date": "2026-05-27",
    "change": "patch",
    "anchor": "swift-1.4.9",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.4.9",
    "markdown": "`.crossdeckTap(\"Name\")` — explicit tap-label bolt-on for SwiftUI\nbuttons whose label the auto-track can't reach on iOS 16+.\n\n**Added:**\n\n- **`View.crossdeckTap(\"Name\", properties:)`** — public SwiftUI\n  modifier that fires `element.clicked` via `.simultaneousGesture`\n  (the button's own action still runs). One line per important\n  CTA — matches the `.crossdeckScreen` pattern.\n\n**Why this exists.** v1.4.7 added a walk-up-16 + descendant-search\nto the UIWindow.sendEvent tap-capture path. That fixed SwiftUI\nbutton labels on iOS 13–15 where Buttons rendered through UILabel\nprimitives. iOS 16+ SwiftUI uses the Metal text rendering pipeline\n— `Button(\"Create Image\")`'s text is drawn straight to a CALayer\nand never lives on any `UILabel.text` or `UIView.accessibilityLabel`\nthe runtime can read. Apple's accessibility merge happens at the\nSwiftUI virtual-view layer above UIKit; `UIView.accessibilityLabel`\nstays nil on the rendered hierarchy. This is an Apple-imposed\nlimit, not a CrossDeck regression — Mixpanel, Amplitude, and\nPostHog all ship a per-button modifier as the bank-grade answer.\n\n**Usage:**\n\n```swift\nButton { generateImage() } label: { Text(\"Create Image\") }\n    .crossdeckTap(\"Create Image\")\n```\n\nComposes onto any tappable View (Image with onTapGesture, custom\ncard layouts, list rows) — not just Button. Properties flow\nthrough `cd.track` so coercion / size guard / cross-SDK label\nresolution on the dashboard apply unchanged."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.4.8",
    "date": "2026-05-27",
    "change": "patch",
    "anchor": "swift-1.4.8",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.4.8",
    "markdown": "Rename `enum Environment` → `enum CrossdeckEnvironment`. Structural\nfix for the SwiftUI symbol collision — closes friction #19 deeper\nthan v1.4.4 caught it.\n\n**Why:** v1.4.4 added `typealias CrossdeckEnvironment = Environment`\nto disambiguate consumer code that imports both `SwiftUI` and\n`Crossdeck`. That helped consumers but left the module-level public\n`Environment` enum in scope inside the SDK itself. When v1.4.5\nshipped `View.crossdeckScreen(\"Name\")`, the new `ViewModifier`\ncontained `@Environment(\\.crossdeck) private var cd` — and the\ncompiler resolved `Environment` to the SDK's enum (closer than\nSwiftUI's), failing with `'Environment' cannot be used as an\nattribute`. The SDK author shipped a public type that collided\nwith the most-used SwiftUI property wrapper for their own code.\n\n**Fix:** rename the source enum to `CrossdeckEnvironment` and\nremove the typealias entirely. There is no short form — the\nqualified name is the only public name. Inside the SDK module\n`@Environment(...)` now resolves to SwiftUI's wrapper\nunambiguously, and consumer code that already follows the docs\n(`let environment: CrossdeckEnvironment = .production`) is\nunchanged.\n\n**Breaking:** consumer code using `Environment.production` /\n`Environment.sandbox` must use `CrossdeckEnvironment.production` /\n`CrossdeckEnvironment.sandbox`. Pre-launch, no shipping consumer\nrelies on the short name — all install snippets and dogfood code\nalready use the qualified form."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "1.5.1",
    "date": "2026-05-27",
    "change": "patch",
    "anchor": "web-1.5.1",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v1.5.1",
    "markdown": "`crossdeck.contract_failed` is now single-fire to a dedicated\nreliability endpoint instead of the customer's `track()` pipeline.\nIndependent-controller flow per Privacy Policy §6; schema-locked by\n`contracts/diagnostics/contract-failed-payload-schema-lock.json`.\n`ContractFailureInput.extra` removed (schema-lock forbids unbounded\nfields); `ContractFailureInput.deviceClass` added.\n\n**Runtime contract verifier layer.** The SDK now self-tests its\nown structural contracts at runtime — per-user cache isolation,\nidempotency-key determinism, error-envelope shape, flush-interval\nparity, super-property merge precedence. Verifiers run on every\nrelevant SDK operation; PASS results stream to the developer's\nconsole when `logVerifierResults: true`; FAIL results fire\n`reportContractFailure(...)` to the reliability channel.\n\nThree new `CrossdeckOptions` flags:\n  - `verifyContractsAtBoot` — default dev=true, prod=false\n  - `logVerifierResults` — default dev=true, prod=false (cosmetic only)\n  - `disableContractAssertions` — sovereignty kill-switch, default false\n\nBundle-size budget bumped 45 → 55 KB gzipped (core) and 26 → 32 KB\n(UMD) to accommodate the ~6 KB verifier framework + verifier\nimplementations. The platform-hardening signal — every install\nin the field tests its own structural contracts as it operates and\nreports failures to Crossdeck's reliability workspace in real time —\nis the trade-off."
  },
  {
    "sdk": "node",
    "sdkLabel": "Node",
    "version": "1.5.0",
    "date": "2026-05-26",
    "change": "minor",
    "anchor": "node-1.5.0",
    "install": "npm install @cross-deck/node@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/node",
    "github": "https://github.com/VistaApps-za/crossdeck-node/releases/tag/v1.5.0",
    "markdown": "Minor — `CrossdeckContracts` + `reportContractFailure(...)` ship as a\nnew public surface on every SDK simultaneously. Additive only; no\nbehavioural change to existing APIs.\n\n**Added:**\n\n- **`CrossdeckContracts` namespace** — typed access to the bank-grade\n  contract registry. Methods: `all()`, `allIncludingHistorical()`,\n  `byId(id)`, `byPillar(pillar)`, `withStatus(status)`,\n  `findByTestName(name)`. Properties: `sdkVersion`, `bundledIn`\n  (e.g. `\"@cross-deck/node@1.5.0\"`).\n- **`Contract` type + `ContractPillar` / `ContractStatus` /\n  `ContractAppliesTo` unions + `ContractTestRef` + `ContractFailureInput`\n  interfaces** exported from the top-level entry. Treated as\n  binary-stable.\n- **`CrossdeckServer.reportContractFailure(input)` method** — fires a\n  typed `crossdeck.contract_failed` server event through the standard\n  `track()` pipeline. Wire properties: `contract_id`, `sdk_version`\n  (auto-stamped), `sdk_platform` (auto-stamped to `\"node\"`),\n  `failure_reason`, `run_context` (`ci` | `dogfood` | `customer-app`),\n  `run_id`, plus optional `test_file` / `test_name` from `input.testRef`.\n\n**Fixed:**\n\n- `shutdownSync()` now emits the `sdk.shutdown` EventEmitter signal\n  with the correct reason — previously only the async `shutdown()`\n  path emitted, leaving consumers of `Symbol.dispose` /\n  `shutdownSync()` direct-callers blind. Async path is unchanged\n  thanks to a private dedup gate so listeners still fire exactly\n  once per teardown.\n- Test infrastructure: shutdown-flush + track-PII-scrub tests were\n  reading `body.data` from captured fetch payloads but the wire\n  shape uses `body.events` (matching backend + Web/RN SDKs). Tests\n  fixed to read the correct field; behaviour was already correct.\n\n**Changed:**\n\n- Contract registry source files migrated to camelCase keys\n  (`appliesTo`, `codeRef`, `testRef`, `registeredAt`,\n  `firstRegisteredIn`). The bundled `contracts.json` sidecar uses\n  the new keys; `bundledIn` is build-stamped, never in source."
  },
  {
    "sdk": "node",
    "sdkLabel": "Node",
    "version": "1.4.2",
    "date": "2026-05-26",
    "change": "patch",
    "anchor": "node-1.4.2",
    "install": "npm install @cross-deck/node@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/node",
    "github": "https://github.com/VistaApps-za/crossdeck-node/releases/tag/v1.4.2",
    "markdown": "Patch — fix `tests/shutdown-flush.test.ts` compile error under\nstrict tsc. The five `s.track(\"name\", { props })` calls used the\nweb/RN positional-args shape; Node SDK's track takes a single\n`ServerEvent` object. Switched to `s.track({ name, properties })`.\nPlus a non-null assertion on `sent[0].length` for\n`noUncheckedIndexedAccess`. v1.4.1 was tagged on the public\ncrossdeck-node repo but its publish workflow aborted on these\nerrors. v1.4.2 is the first 1.4.x line to land on the npm\nregistry. **No SDK code changes vs v1.4.0 / v1.4.1**."
  },
  {
    "sdk": "node",
    "sdkLabel": "Node",
    "version": "1.4.1",
    "date": "2026-05-26",
    "change": "patch",
    "anchor": "node-1.4.1",
    "install": "npm install @cross-deck/node@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/node",
    "github": "https://github.com/VistaApps-za/crossdeck-node/releases/tag/v1.4.1",
    "markdown": "Patch — add automated npm publish workflow to the public\n`crossdeck-node` repo so future `vX.Y.Z` tag pushes auto-publish\nto npm via OIDC Trusted Publishing (matches the existing\n`crossdeck-web` pattern). Also strips `test:e2e` from\n`prepublishOnly` — the publish workflow runs lint + unit tests +\nbuild which covers the release gate. No SDK code changes vs\nv1.4.0.\n\n**Operator note:** npmjs.com Trusted Publisher rule must be\nconfigured for `crossdeck-node` (owner: VistaApps-za,\nworkflow: publish.yml) before the OIDC publish succeeds. First\npublish after this lands will fail with an auth error if the\nrule is missing — that's the prompt to configure it."
  },
  {
    "sdk": "node",
    "sdkLabel": "Node",
    "version": "1.4.0",
    "date": "2026-05-26",
    "change": "minor",
    "anchor": "node-1.4.0",
    "install": "npm install @cross-deck/node@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/node",
    "github": "https://github.com/VistaApps-za/crossdeck-node/releases/tag/v1.4.0",
    "markdown": "**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.\n\n### Added\n\n- **PII scrubber applied on `track()` enqueue path** — parity with Web/RN/Swift. Pre-1.4.0 Node was the ONLY SDK that skipped this, shipping payloads UNREDACTED. New `scrubPii?: boolean` option (default true); explicit false opt-out preserves raw payloads for regulator-required audit trails.\n- **Deterministic `Idempotency-Key` on `syncPurchases()`** — same JWS/purchaseToken → same key. New `options.idempotencyKey` override for outer orchestrators.\n- **`PurchaseResult.idempotent_replay?: boolean`** — true when the backend replayed a cached response.\n- **`purchase.completed` event on every successful `syncPurchases()`** — funnel parity with Swift/Android auto-track.\n- **Distinguishable webhook verifier error codes** — pre-1.4.0 collapsed everything into `webhook_invalid_signature`. New: `webhook_signature_mismatch` (wrong-secret signal), `webhook_timestamp_outside_tolerance` (replay-attack signal — alert separately), `webhook_timestamp_missing`, `webhook_payload_not_json`, `webhook_invalid_tolerance`. Legacy codes deprecated with migration notes.\n- **Webhook verifier rejects footgun tolerances** — `Infinity` / `NaN` / negative / above-24h-cap now throw `webhook_invalid_tolerance` instead of silently disabling replay protection.\n- **15 backend-emitted error codes** added to the `crossdeck-error-codes.json` catalogue with Stripe-style remediation guidance.\n\n### Changed (breaking)\n\n- **`shutdown()` signature changed from `(reason) => void` to `(reason) => Promise<void>`.** Awaits `flush()` before tearing down the queue. Pre-1.4.0 it called `eventQueue.reset()` synchronously — every event between the last flush and shutdown was silently dropped. New `shutdownSync()` for callers that genuinely cannot await (signal handlers); it logs `console.warn` with the dropped-event count if the buffer is non-empty.\n- **Default event-queue flush interval is now 2000ms** (was 1500ms) — cross-SDK parity.\n- **`[Symbol.dispose]` now warns when dropping queued events.** Use `await using` + `[Symbol.asyncDispose]` (or `await server.shutdown()`) for proper drainage."
  },
  {
    "sdk": "react-native",
    "sdkLabel": "React Native",
    "version": "1.5.0",
    "date": "2026-05-26",
    "change": "minor",
    "anchor": "react-native-1.5.0",
    "install": "npm install @cross-deck/react-native@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/react-native",
    "github": "https://github.com/VistaApps-za/crossdeck-react-native/releases/tag/v1.5.0",
    "markdown": "Minor — `CrossdeckContracts` + `reportContractFailure(...)` ship as a\nnew public surface on every SDK simultaneously. Additive only; no\nbehavioural change to existing APIs.\n\n**Added:**\n\n- **`CrossdeckContracts` namespace** — typed access to the bank-grade\n  contract registry. Methods: `all()`, `allIncludingHistorical()`,\n  `byId(id)`, `byPillar(pillar)`, `withStatus(status)`,\n  `findByTestName(name)`. Properties: `sdkVersion`, `bundledIn`\n  (e.g. `\"@cross-deck/react-native@1.5.0\"`).\n- **`Contract` type + `ContractPillar` / `ContractStatus` /\n  `ContractAppliesTo` unions + `ContractTestRef` + `ContractFailureInput`\n  interfaces** exported from the top-level entry. Treated as\n  binary-stable.\n- **`Crossdeck.reportContractFailure(input)` method** — fires a typed\n  `crossdeck.contract_failed` event through the standard `track()`\n  pipeline when a contract test asserts and fails. Wire properties:\n  `contract_id`, `sdk_version` (auto-stamped), `sdk_platform`\n  (auto-stamped to `\"react-native\"`), `failure_reason`, `run_context`\n  (`ci` | `dogfood` | `customer-app`), `run_id`, plus optional\n  `test_file` / `test_name`.\n\n**Fixed:**\n\n- Pre-hydration `track()` calls now correctly snapshot the\n  call-time `sessionId` and thread it through the deferred\n  enrichment body. Previously, two `track()` calls separated by\n  `setSessionId(...)` BEFORE hydration completed would both pick up\n  whatever `sessionId` was current at hydration resolution — silently\n  rewriting the first event with the second event's state. This\n  contract is RN-specific (Web/Node have no hydration window).\n\n**Changed:**\n\n- Contract registry source files migrated to camelCase keys\n  (`appliesTo`, `codeRef`, `testRef`, `registeredAt`,\n  `firstRegisteredIn`). The bundled `contracts.json` sidecar uses\n  the new keys; `bundledIn` is build-stamped, never in source."
  },
  {
    "sdk": "react-native",
    "sdkLabel": "React Native",
    "version": "1.4.2",
    "date": "2026-05-26",
    "change": "patch",
    "anchor": "react-native-1.4.2",
    "install": "npm install @cross-deck/react-native@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/react-native",
    "github": "https://github.com/VistaApps-za/crossdeck-react-native/releases/tag/v1.4.2",
    "markdown": "Patch — wire `bundleId` + `packageName` (per-platform identity-\nlock fields declared on `CrossdeckOptions` since v1.3.0) into\nthe `InternalState` opts merge. tsc accepted the missing fields\nin monorepo CI because the monorepo test workflow doesn't lint\nthe RN SDK — only the Web SDK gets type-checked. The public\ncrossdeck-react-native publish workflow DOES run `npm run lint`\nand aborted with TS2322. Fix: default both to empty string in\nthe opts initialiser (HTTP layer skips the header when empty;\nbackend rejects with bundle_id_not_allowed /\npackage_name_not_allowed at first request if the project\nrequires the lock — intentional fail-closed). v1.4.1 was\ntagged on crossdeck-react-native but never reached npm.\n**No SDK code changes vs v1.4.0 / v1.4.1**."
  },
  {
    "sdk": "react-native",
    "sdkLabel": "React Native",
    "version": "1.4.1",
    "date": "2026-05-26",
    "change": "patch",
    "anchor": "react-native-1.4.1",
    "install": "npm install @cross-deck/react-native@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/react-native",
    "github": "https://github.com/VistaApps-za/crossdeck-react-native/releases/tag/v1.4.1",
    "markdown": "Patch — add automated npm publish workflow to the public\n`crossdeck-react-native` repo so future `vX.Y.Z` tag pushes\nauto-publish to npm via OIDC Trusted Publishing (matches the\nexisting `crossdeck-web` pattern). No SDK code changes vs v1.4.0.\n\n**Operator note:** npmjs.com Trusted Publisher rule must be\nconfigured for `crossdeck-react-native` (owner: VistaApps-za,\nworkflow: publish.yml) before the OIDC publish succeeds. First\npublish after this lands will fail with an auth error if the\nrule is missing — that's the prompt to configure it."
  },
  {
    "sdk": "react-native",
    "sdkLabel": "React Native",
    "version": "1.4.0",
    "date": "2026-05-26",
    "change": "minor",
    "anchor": "react-native-1.4.0",
    "install": "npm install @cross-deck/react-native@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/react-native",
    "github": "https://github.com/VistaApps-za/crossdeck-react-native/releases/tag/v1.4.0",
    "markdown": "**Bank-grade reconciliation release.** Joined the v1.4.0 release line with the rest of the Crossdeck SDK suite. 6-pillar KPMG-style audit closed; every behavioural guarantee registered in the monorepo's `contracts/` directory with a CI-enforced audit job.\n\n### Added\n\n- **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.\n- **Deterministic `Idempotency-Key` on `syncPurchases()`** — same JWS/purchaseToken → same key. Cross-SDK parity oracle CI-pinned.\n- **`PurchaseResult.idempotent_replay?: boolean`** — true when the backend replayed a cached response.\n- **`purchase.completed` event on every successful `syncPurchases()`** — funnel parity with native auto-track.\n- **`setSessionId(sessionId: string | null)`** — host-driven session lifecycle. Call from your AppState change listener so every `track()` event carries the `sessionId` property — funnel parity with the web SDK.\n\n### Changed\n\n- **`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.\n- **Default event-queue flush interval is now 2000ms** (was 5000ms) — cross-SDK parity."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.4.7",
    "date": "2026-05-26",
    "change": "patch",
    "anchor": "swift-1.4.7",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.4.7",
    "markdown": "Auto-track tap labels now resolve on SwiftUI buttons — closes the\n\"Clicked an element\" gap that buried real CTAs on the dashboard.\n\n**Fixed:**\n\n- The `UIWindow.sendEvent` tap-capture walked up only 4 ancestors\n  looking for an `accessibilityLabel`. SwiftUI's button hosting tree\n  is much deeper — `Button(\"Create Image\") { … }` puts the merged\n  accessibility label on a view 8–12 hops above the touched\n  `Text` / `Image`. Bumped the walk-up to 16 ancestors so the label\n  is reachable.\n- New descendant-search fallback. SwiftUI's accessibility-merge\n  model commonly puts the human-readable label on a SIBLING or a\n  descendant of the hit-test target rather than an ancestor. When\n  the ancestor walk-up returns nothing, the SDK now descends up to\n  6 levels into the touched view's subtree looking for a `UILabel`\n  with text or a view carrying an `accessibilityLabel`. First match\n  wins — closest, shallowest descendant.\n- New `textIndicatesPII` helper applies the same `password` /\n  `card number` / `ssn` substring filter to descendant-found text\n  as to ancestor accessibilityLabel — so a password field's visible\n  text never lands on the wire.\n\nResult: a SwiftUI `Button(\"Create Image\") { … }` tap now ships\n`element.clicked` with `accessibilityLabel = \"Create Image\"`, and\nthe Pages dashboard / live feed / per-person journey all render\n**\"Clicked 'Create Image'\"** instead of \"Clicked an element.\""
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.4.6",
    "date": "2026-05-26",
    "change": "patch",
    "anchor": "swift-1.4.6",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.4.6",
    "markdown": "SwiftUI hosting-controller denylist — the Pages dashboard no longer\nfills up with framework noise.\n\n**Fixed:**\n\n- The auto-track UIViewController-appearance swizzle was leaking\n  SwiftUI's internal hosting controllers (`PresentationHostingController<AnyView>`,\n  `NavigationStackHostingController<AnyView>`, `UIKitNavigationController`)\n  as `page.viewed` events. The existing denylist caught\n  `UIHostingController` (top-level SwiftUI host) but missed the\n  newer hosting machinery SwiftUI 5 / iOS 16+ added for sheets and\n  NavigationStack. Result on a pure-SwiftUI app: the Pages dashboard\n  filled with framework class names instead of the developer's real\n  screens.\n- New `screenViewClassSubstringDenylist` skips any class containing\n  `HostingController` — catches all current and future SwiftUI host\n  variants (including any prefixed by SwiftUI's mangled namespace).\n- `UIKitNavigationController` (Apple's private UINavigationController\n  subclass that backs NavigationStack) added to the exact-name\n  denylist. It was firing on every navigation push and burying the\n  developer's real screens with 11+ noise events per nav.\n\nThe right path for SwiftUI screen names remains `.crossdeckScreen(\"Name\")`\n(shipped in v1.4.5) — these denylist additions just stop the\nfallback from producing noise when the modifier isn't applied yet."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.4.5",
    "date": "2026-05-26",
    "change": "patch",
    "anchor": "swift-1.4.5",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.4.5",
    "markdown": "SwiftUI screen tracking — the iOS half of \"log in tomorrow morning\nand see what users tapped on yesterday.\"\n\n**Added:**\n\n- **`View.crossdeckScreen(\"Name\")`** — public SwiftUI modifier that\n  fires `page.viewed` with `{ screen, title }` properties when the\n  view appears. SwiftUI's view tree hides class names from the\n  iOS SDK's swizzle-based auto-track (the host is always\n  `UIHostingController<…>`, denylisted for the right reason), so\n  pure-SwiftUI apps emitted zero `page.viewed` events. One line\n  per screen and the Pages dashboard populates the same way it\n  does for a web app's URL list. Matches the pattern Mixpanel /\n  Amplitude / PostHog ship on iOS; pairs with the Pages-backend\n  change that groups by `screen` when `url` / `path` are absent.\n  Properties are also forwarded through `cd.track(...)` so the\n  standard coercion + size guard apply.\n\nNo behavioural change to existing UIKit auto-track — UIKit screens\nkeep firing `page.viewed` automatically via the\n`UIViewController.viewDidAppear` swizzle (unchanged)."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.4.4",
    "date": "2026-05-26",
    "change": "patch",
    "anchor": "swift-1.4.4",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.4.4",
    "markdown": "Bank-grade SwiftUI integration polish. The Quickstart snippet now\npastes and runs end-to-end without the developer having to\nhand-roll any wiring.\n\n**Added:**\n\n- **`EnvironmentValues.crossdeck`** — public SwiftUI environment key\n  shipped by the SDK. Consumers can now write\n  `ContentView().environment(\\.crossdeck, cd)` at the App root and\n  `@Environment(\\.crossdeck) private var cd` inside any view, with\n  zero `EnvironmentKey` boilerplate on the consumer side. Returns\n  `Crossdeck?` so the optional-chain pattern at every call site\n  keeps the host app crash-proof when the SDK didn't start. Lives\n  under `#if canImport(SwiftUI)` so non-Apple-platform resolves\n  still compile.\n- **`CrossdeckEnvironment`** — public typealias for the SDK's\n  `Environment` enum. Avoids the symbol collision every SwiftUI\n  consumer hits the moment they `import Crossdeck` alongside\n  `import SwiftUI` (SwiftUI's own `Environment` property wrapper).\n  The bare `Environment` name stays exported for back-compat and\n  keeps working unqualified in files that don't import SwiftUI.\n\n**Changed:**\n\n- Quickstart snippet (dashboard SDKs page → iOS) now uses\n  `CrossdeckEnvironment` in place of bare `Environment`, so the\n  copy-pasted boot code compiles cleanly inside a `@main App`\n  file that also imports SwiftUI.\n\nNo behavioural change; both additions are purely ergonomic surface\nthat closes the \"first dogfood paste-and-run\" friction loop."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.4.3",
    "date": "2026-05-26",
    "change": "patch",
    "anchor": "swift-1.4.3",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.4.3",
    "markdown": "Patch — second consumer compile fix surfaced by the same dogfood\nproject as v1.4.2. With v1.4.2's `idempotentReplay` field in place,\nSwift's type-checker progressed further and pinned a separate\nissue in `Crossdeck.init(options:)`:\n\nThe auto-tracked `purchases/sync` failure path's debug-logger call\npassed `typed.statusCode as Any` into a `[String: String]` dict\nliteral. `debugLogger` is typed `(DebugSignal, [String: String]) ->\nVoid` (Stripe-style structured-payload contract — no `Any` smuggled\nthrough the diagnostic surface). The dict literal rejected the\n`Any` cast and emitted two cascading errors.\n\n**Fixed:**\n\n- `Crossdeck.swift:614` — replace `typed.statusCode as Any` with\n  `typed.statusCode.map(String.init) ?? \"n/a\"`. `statusCode` is\n  `Int?` — when present we stringify, when absent we emit `\"n/a\"`\n  so the structured key is always populated.\n\nNo behavioural change; the debug payload now strictly conforms to\nthe typed shape the rest of the SDK already uses. Bundled\ncontracts (`Resources/contracts.json`) regenerated so `bundledIn`\nstamps `@cross-deck/swift@1.4.3`."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.4.2",
    "date": "2026-05-26",
    "change": "patch",
    "anchor": "swift-1.4.2",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.4.2",
    "markdown": "Patch — fix consumer compile error caused by a missing field on\n`PurchaseResult`. Surfaced when the first iOS dogfood project pulled\nv1.4.1 via SwiftPM and Xcode rejected the SDK source with two\ncascading errors in `syncPurchases(...)`:\n\n- `Value of type 'PurchaseResult' has no member 'idempotent_replay'`\n- `Cannot convert value of type 'Any' to expected dictionary value\n  type 'String'` (cascade — the Swift type-checker gives up on\n  `[String: Any]` inference once a member resolves unknown)\n\n**Fixed:**\n\n- `PurchaseResult` gains `public let idempotentReplay: Bool?` with\n  a `CodingKey` mapping the Swift-idiomatic camelCase property to\n  the wire's `idempotent_replay` (snake_case, set by the backend's\n  idempotency-response-cache middleware on cache-hit retries per\n  the `idempotency-key-deterministic` contract).\n- `syncPurchases(...)` now reads `result.idempotentReplay`. The\n  analytics event property key stays `idempotent_replay` on the\n  wire so dashboards joining `purchase.completed` across SDKs see\n  the same key Web/Node/RN/Android already emit.\n\nNo new API surface; no behavioural change beyond the typed access\nunlocking what the runtime was already receiving. Bundled\ncontracts (`Resources/contracts.json`) regenerated so `bundledIn`\nstamps `@cross-deck/swift@1.4.2`."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.4.1",
    "date": "2026-05-26",
    "change": "patch",
    "anchor": "swift-1.4.1",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.4.1",
    "markdown": "Patch — close the dogfood-surfaced gap on the\n`per-user-cache-isolation` contract. v1.4.0 registered the contract\nwith `applies_to: [\"web\", \"react-native\"]` because Swift + Android\nonly shipped the in-memory wipe layer of the three-layer bank-grade\nisolation — physical per-user storage keys + the clearAll-via-index\nlogout wipe were missing.\n\n**Implemented in v1.4.1 (now in the contract's applies_to list):**\n- `EntitlementCache.setUserKey(userId)` /\n  `setUserKeySync(userId)` flip the persistent storage suffix to\n  `sha256(userId)` so each user's blob lives under\n  `crossdeck:entitlements:<hash>` — a user-switch on a shared\n  device CANNOT cross-read prior user's data even if the\n  in-memory wipe is somehow skipped.\n- `EntitlementCache.clearAll()` reads the persisted suffix index\n  and wipes every per-user slot — used by `Crossdeck.reset()` so\n  a logout on a shared device cannot leave another user's\n  entitlements readable.\n- `Crossdeck.identify(userId)` calls `setUserKeySync(userId)`\n  instead of `clearSync()`.\n- `Crossdeck.reset()` (async) calls `clearAll()` instead of\n  `clear()`.\n\nNo public API breakage; existing `identify()` / `reset()`\nsemantics upgrade from \"in-memory only\" to the full three-layer\ncontract."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.4.0",
    "date": "2026-05-26",
    "change": "minor",
    "anchor": "swift-1.4.0",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.4.0",
    "markdown": "**Bank-grade reconciliation release.** 6-pillar KPMG-style audit\nclosed across SDK + backend. Every behavioural guarantee registered\nin the monorepo's `contracts/` directory with a CI-enforced audit job.\n\n### Added\n\n- **`PurchaseAutoTrack` purchase durability.** `transaction.finish()`\n  is now called STRICTLY inside the success branch of the backend\n  sync. Pre-1.4.0 it fired regardless of outcome — a 5xx mid-process-\n  death silently lost the purchase. Failed syncs persist to a new\n  `PendingPurchaseQueue` (max 5 in-process retries, exp backoff\n  30s/1m/5m/30m/2h).\n- **Proper `appAccountToken` UUID conformance.** Derived from\n  `developerUserId` via `AppAccountTokenDerivation` (UUID\n  passthrough, else UUID v5 from URL namespace + `crossdeck:<id>`,\n  else omit). Numeric StoreKit `originalTransactionId` now rides\n  in its own dedicated wire field — pre-1.4.0 it was stuffed into\n  the UUID-shaped `appAccountToken`, violating Apple's StoreKit\n  contract.\n- **Deterministic `Idempotency-Key` on `syncPurchases()`** — same\n  JWS → same key. Cross-SDK parity oracle CI-pinned.\n- **`PurchaseResult.idempotent_replay?: Bool`** — true when the\n  backend replayed a cached response.\n- **`purchase.completed` on every successful manual\n  `syncPurchases()`** — funnel parity with auto-track.\n\n### Changed (breaking)\n\n- **`reset()` is now `async`**. Awaits identity / entitlements /\n  super-properties / breadcrumbs clear before returning. New\n  `isResetting` tombstone flips synchronously at entry; `isEntitled`\n  honours it and returns false during the clear window — closes\n  the race between a logout button firing reset() and the actor-\n  internal clear completing. `resetSync()` exists for callers that\n  cannot await.\n- **`stop()` is now `async`**. Awaits `queue.persistAll()` and\n  cancels stored boot + heartbeat Tasks. Pre-1.4.0 the Tasks ran\n  fire-and-forget against actors of stopped clients. `stopSync()`\n  exists for tests / deinit paths.\n- **`CrossdeckErrorType.internalError` / `.configurationError`\n  added; `.apiError` / `.unknown` deprecated** with `@available(*,\n  deprecated, renamed:)`. Backend's `ApiErrorType` never emitted\n  `\"api_error\"` or `\"unknown_error\"` on the wire — native pattern-\n  matching on the deprecated cases only matched the SDK-synthesised\n  fallback, never a real backend envelope. Use `.internalError`\n  for 5xx responses.\n\n### Added (continued)\n\n- **`NSNotificationCenter` observer cleanup in `stop()`.** Pre-1.4.0\n  every start→stop→start cycle leaked N orphan observers; each\n  subsequent didEnterBackground fired N stacked queue.flush() against\n  dead Crossdecks. Stored tokens, removed via\n  `uninstallLifecycleObservers()`.\n- **`ErrorCapture.shared.uninstall()` called in `stop()`.** Pre-1.4.0\n  the global exception handler retained queue/identity/consent/\n  breadcrumb actors of the stopped client; next uncaught exception\n  shipped through dead actors.\n- **Super-property merge order matches Web/Node/RN** — device <\n  super < caller. Pre-1.4.0 Swift had it inverted (super < device <\n  caller, so device clobbered super-properties).\n- **Default event-queue flush interval is now 2000ms** (was 5000ms)\n  — cross-SDK parity."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "1.5.0",
    "date": "2026-05-26",
    "change": "minor",
    "anchor": "web-1.5.0",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v1.5.0",
    "markdown": "Minor — `CrossdeckContracts` + `reportContractFailure(...)` ship as a\nnew public surface on every SDK simultaneously. Additive only; no\nbehavioural change to existing APIs.\n\n**Added:**\n\n- **`CrossdeckContracts` namespace** — typed, tree-shakeable access to\n  the bank-grade contract registry the SDK was already shipping in\n  `dist/contracts.json`. Methods: `all()` (enforced only),\n  `allIncludingHistorical()`, `byId(id)`, `byPillar(pillar)`,\n  `withStatus(status)`, `findByTestName(name)`. Properties:\n  `sdkVersion`, `bundledIn` (e.g. `\"@cross-deck/web@1.5.0\"`).\n- **`Contract` type + `ContractPillar` / `ContractStatus` /\n  `ContractAppliesTo` unions + `ContractTestRef` interface** exported\n  from the top-level entry. Treated as binary-stable — fields may be\n  added in any minor release but never removed/repurposed except in a\n  major bump.\n- **`Crossdeck.reportContractFailure(input)` method** — fires a\n  typed `crossdeck.contract_failed` custom event through the\n  standard `track()` pipeline when a contract test asserts and\n  fails (in CI, dogfood, or a customer integration test). Wire\n  properties: `contract_id`, `sdk_version` (auto-stamped),\n  `sdk_platform` (auto-stamped to `\"web\"`), `failure_reason`,\n  `run_context` (`ci` | `dogfood` | `customer-app`), `run_id`, and\n  optional `test_file` / `test_name` from `input.testRef`.\n- **Bundle size**: core ESM/CJS/react/vue budgets raised to 45 KB\n  gzipped (from 41 KB), UMD min to 26 KB (from 23 KB) to accommodate\n  the inlined contracts dataset (~3 KB gzipped) + the query helpers\n  + the new public types. Still well below every single-pillar\n  competitor's ceiling (Mixpanel 55, Sentry 30 errors-only, PostHog\n  40 analytics-only) for a one-bundle three-pillar SDK that now\n  also ships its own verification dataset.\n\n**Changed:**\n\n- Contract registry source files migrated from snake_case to camelCase\n  keys (`appliesTo`, `codeRef`, `testRef`, `registeredAt`,\n  `firstRegisteredIn`). The bundled `contracts.json` sidecar shipped\n  with this release uses the new keys. `bundledIn` is added at build\n  time, never present in source. See [`contracts/README.md`](https://github.com/VistaApps-za/crossdeck/blob/main/contracts/README.md)\n  for the schema rationale and `firstRegisteredIn` (immutable) vs\n  `bundledIn` (build-stamped) split."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "1.4.2",
    "date": "2026-05-26",
    "change": "patch",
    "anchor": "web-1.4.2",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v1.4.2",
    "markdown": "Patch — second npm publish pipeline fix. v1.4.1 fixed the Node 24\n`navigator` test mutation, but the `prepublishOnly` hook still ran\nthe Playwright e2e suite at `npm publish` time even though the\npublish workflow doesn't install Chromium. Removed `test:e2e` from\n`prepublishOnly` — the publish workflow runs lint + unit tests +\nbuild + size budget which covers everything except the\nbrowser-bound e2e (which requires Playwright setup the publish\nworkflow doesn't provide; e2e still runs in monorepo CI). v1.4.2\nis the first 1.4.x line to actually land on the npm registry.\n**No SDK code changes vs v1.4.0 / v1.4.1**."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "1.4.1",
    "date": "2026-05-26",
    "change": "patch",
    "anchor": "web-1.4.1",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v1.4.1",
    "markdown": "Patch — Node 24 compatibility fix for the npm publish pipeline. The\n`consent.test.ts` DNT cases mutated `globalThis.navigator` via direct\nassignment; Node 24 (the public crossdeck-web repo's npm publish\nworkflow Node version) made navigator a read-only getter, so the\ntest threw `TypeError: Cannot set property navigator` and aborted\nthe publish. Pattern switched to `Object.defineProperty`. v1.4.0\nwas tagged on the public GitHub repo but never reached npm — v1.4.1\nis the first 1.4.x line to land on the npm registry. **No SDK code\nchanges vs v1.4.0**; the entire bank-grade reconciliation surface\ndocumented below ships unchanged."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "1.4.0",
    "date": "2026-05-26",
    "change": "minor",
    "anchor": "web-1.4.0",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v1.4.0",
    "markdown": "**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.\n\n### Added\n\n- **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.\n- **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).\n- **`PurchaseResult.idempotent_replay?: boolean`** — true when the response came from the backend's idempotency cache instead of fresh processing.\n- **`purchase.completed` event on every successful `syncPurchases()`** — schema matches the auto-track event so cross-platform funnels reconcile.\n- **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`.\n\n### Changed\n\n- **`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.\n- **Default event-queue flush interval is now 2000ms** (was 1500ms) — parity with every other Crossdeck SDK on the Stripe-adjacent industry norm.\n- **`reset()` now wipes every per-user entitlement slot on the device** via the persisted index, not just the active user's slot.\n\nPatch fix for the 1.3.0 dist-load contract. 1.3.0 introduced\n`import { version } from \"../package.json\"` to keep the runtime\n`Crossdeck-Sdk-Version` header in lockstep with the published bundle.\nEsbuild inlined the JSON correctly so the published bundle still\nshipped the right version on the wire, but the `dist-loading` test\nthat dynamic-imports the built `.mjs` files was hitting Vitest's 5s\ndefault test timeout while Node evaluated the bundle.\n\n### Fixed\n\n- **Removed the runtime JSON import.** `SDK_VERSION` is now sourced\n  from a generated `src/_version.ts` file (produced by\n  `scripts/sync-sdk-versions.mjs` from `package.json`). The wire\n  contract is unchanged; the build artefact no longer carries a\n  JSON-module dependency that Node ESM requires\n  `with { type: \"json\" }` to load from a `.mjs` file.\n- **dist-loading test timeout bumped to 60s.** The dynamic-imports of\n  100KB+ bundles are genuinely slow on cold Node (~45s measured for\n  `vue.mjs`); the assertions themselves are sub-millisecond.\n\n1.3.0 was never published to npm; the only consumers are the public\nGitHub repo's v1.3.0 tag (left in place for traceability). 1.3.1 is\nthe first 1.3.x line to reach npm."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.3.0",
    "date": "2026-05-25",
    "change": "minor",
    "anchor": "swift-1.3.0",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.3.0",
    "markdown": "Bank-grade identity lock — the Apple Bundle ID is now sent on\nevery request and enforced server-side, mirroring the Origin\nlock the Web SDK has always had.\n\n### Added — Apple Bundle ID identity claim\n\nEvery HTTP request the SDK fires now carries an\n`X-Crossdeck-Bundle-Id` header sourced from\n`Bundle.main.bundleIdentifier` — the OS-canonical ID Apple itself\nuses for App Store identity.\n\nThe Crossdeck backend's `isBundleIdAllowed()` validator enforces\nthis against the bundleId stored on the iOS app key. Requests\nwithout the header, or with a mismatched value, are rejected\nwith `403 / bundle_id_not_allowed`.\n\nBank-grade contract — same shape as the Web SDK's Origin lock:\n- empty stored bundleId on the key → request rejected\n- missing header on the request → request rejected\n- exact-match required (case-sensitive — Apple's own convention)\n\n### Migration\n\nCustomers must:\n1. Bump SPM Dependency Rule to v1.3.0.\n2. Rebuild + resubmit to App Store Connect.\n3. Confirm `apps.ios.bundleId` is set on the project's iOS app\n   in the Crossdeck dashboard (Apps → Bundle ID editor).\n\nApps shipped with v1.2.0 or earlier will start receiving 403s\nonce the backend enforcement deploys, because they don't send\nthe new header."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.2.0",
    "date": "2026-05-25",
    "change": "minor",
    "anchor": "swift-1.2.0",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.2.0",
    "markdown": "Full bank-grade parity with the Web/Node/RN SDKs. v1.1.0 closed the\nergonomics gap (non-throwing track/identify/reset); v1.2.0 closes\nevery remaining gap that a serious customer would notice — auto-\ntracking, performance vitals, mobile lifecycle, App Store privacy\nmanifest, and ambient signal modules.\n\n### Added — Auto-tracking (sessions + screens + taps)\n\nCross-platform event vocabulary identical to Web SDK so a single\ndashboard query returns Web + iOS + Android rows uniformly:\n\n- `session.started` / `session.ended` with `sessionId` + `durationMs`.\n  30-minute idle threshold matches GA4 / Mixpanel / Web SDK\n  convention — a quick app-switch keeps the same session.\n- `page.viewed` — fires automatically on every `UIViewController.viewDidAppear`\n  via method swizzling. Skips framework hosts (UINavigationController,\n  UIHostingController, _SwiftUI types). 250ms dedup window collapses\n  push/pop animation double-fires.\n- `element.clicked` — fires on every UIControl action (UIButton,\n  UISwitch, UISlider, UISegmentedControl) AND on SwiftUI button taps\n  via UIWindow.sendEvent capture. Captures accessibilityLabel,\n  accessibilityIdentifier, class name, viewport coordinates.\n\nEvery event is enriched with the current `sessionId` so funnels work\nwithout explicit instrumentation.\n\nPrivacy guardrails baked in:\n- Secure text fields, accessibility labels containing `password` /\n  `card` / `ssn` / `credit` / `cvv` / `pin` are skipped silently.\n- Opt-out per element via `accessibilityIdentifier` containing\n  `cd-noTrack` — Mixpanel-style convention familiar to iOS devs.\n- 100ms tap-coalesce defeats React-Native-style double-fires.\n\nConfigurable via `CrossdeckOptions(autoTrack: .off)` for strict-\nconsent flows, or feature-grained:\n\n```swift\nCrossdeckOptions(\n    autoTrack: AutoTrackConfig(\n        sessions: true,\n        screenViews: true,\n        taps: false,  // disable tap autocapture only\n        sessionResumeThresholdSeconds: 30 * 60\n    )\n)\n```\n\n### Added — PrivacyInfo.xcprivacy bundled in the SDK\n\nApple began enforcing the required-reason API manifest at App Store\nConnect submit in May 2024. Without one, every embedding app is\nrejected. Crossdeck now ships its own `PrivacyInfo.xcprivacy`\ndeclaring:\n- `NSPrivacyAccessedAPICategoryUserDefaults` reason `CA92.1`\n- `NSPrivacyAccessedAPICategorySystemBootTime` reason `35F9.1`\n- `NSPrivacyTracking: false` (we do not link identity across third\n  parties)\n\nConsumer apps inherit the manifest automatically via SPM's resource\ncopy — no copy-paste, no one-off rejections.\n\n### Added — MetricKit performance vitals (opt-in)\n\nMirrors Web SDK's `web-vitals.ts`. Set\n`CrossdeckOptions(enablePerformanceMonitoring: true)` to receive:\n- `perf.metrics` — daily aggregate (cold launch samples, resume\n  samples, hang samples, peak memory, cumulative CPU).\n- `perf.hang` — near-real-time UI-blocked diagnostics with\n  hangDuration + metadata.\n- `perf.cpu_exception` — sustained CPU spike diagnostics.\n- `perf.disk_write_exception` — high-volume disk write diagnostics.\n- `perf.crash_diagnostic` — MetricKit's process-fatal exception\n  pipeline (complement to `NSSetUncaughtExceptionHandler`).\n\niOS 14+ / macOS 12+. Off by default — payload size is meaningful and\nnot every customer wants the signal.\n\n### Added — Proactive network-edge flush\n\n`NWPathMonitor` watches reachability. On `offline → online`\ntransitions, the event queue flushes immediately instead of waiting\nfor the next 5-second timer. Closes the latency gap on intermittent\nconnections (subway, airplane mode toggle).\n\nON by default via `CrossdeckOptions(enableReachabilityFlush: true)`.\niOS 12+ / macOS 10.14+.\n\n### Added — Automatic StoreKit 2 purchase tracking (opt-in)\n\n`CrossdeckOptions(automaticPurchaseTracking: true)` installs a\n`Transaction.updates` AsyncSequence consumer. Every signed\ntransaction (purchase, restore, renewal, refund, family-shared)\nflows to `/purchases/sync` via the same HTTP path `syncPurchases()`\nuses AND fires a public funnel event:\n- `purchase.completed` for new transactions\n- `purchase.refunded` for revoked transactions (carries\n  revocationReason)\n- `purchase.unverified` for transactions Apple's signature check\n  fails — fraud-signal candidate, never synced to backend\n\niOS 15+. Off by default because most apps already invoke\n`syncPurchases()` from their own confirmation flow.\n\n### Added — Deep-link + push interaction tracking helpers\n\nPublic API surface for the consumer to forward intent from their\nSceneDelegate / UNUserNotificationCenter:\n- `cd.trackDeepLink(url:source:)` — extracts UTM + click-id query\n  parameters (gclid, fbclid, msclkid, ttclid, li_fat_id, twclid)\n  as top-level properties. Fires `deeplink.opened`.\n- `cd.trackPushReceived(userInfo:)` / `trackPushInteraction(userInfo:actionIdentifier:)`\n  — surfaces marketing-platform IDs (campaign_id, message_id, etc.)\n  without logging the alert body. Fires `push.received` /\n  `push.interacted`.\n\n### Fixed — `willTerminate` flush observer\n\nForce-quit from the app switcher previously lost up to one batch\nof queued events. v1.2.0 observes `UIApplication.willTerminateNotification`\nand runs `queue.persistAll()` so the events land on disk before\nthe process dies. Next launch's queue rehydration ships them.\n\n### Fixed — macOS / watchOS lifecycle parity\n\n`Cmd+Q` on a Mac Catalyst or pure-AppKit Crossdeck client previously\nfell off the lifecycle hook (only UIKit was wired). v1.2.0 adds\n`NSApplication.willTerminateNotification` + `WKExtension.applicationDidEnterBackgroundNotification`\nbranches so every Apple OS the SDK targets has a persist-on-suspend\nguarantee.\n\n### Migration\n\nNone required. All new modules are additive or default-OFF where\nthey could be surprising. v1.1.0 call sites compile clean against\nv1.2.0.\n\nTo benefit from auto-tracking, no code change — start using the\ndefaults. Customers who want to disable a specific signal:\n\n```swift\nCrossdeckOptions(\n    // …\n    autoTrack: AutoTrackConfig(taps: false),\n    enableReachabilityFlush: false,\n    enablePerformanceMonitoring: false,    // already default\n    automaticPurchaseTracking: false        // already default\n)\n```"
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.1.0",
    "date": "2026-05-25",
    "change": "minor",
    "anchor": "swift-1.1.0",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.1.0",
    "markdown": "Fire-and-forget API ergonomics — matches Mixpanel / Amplitude /\nSentry / Firebase Analytics iOS conventions. Dogfood feedback flagged\nthat requiring `try?` at every analytics call site is hostile in\nSwift even though Web/Node/RN's `track()` throw — Swift's\ncompile-time enforcement makes the same shape user-hostile.\n\n### Changed — `track`, `identify`, `reset` no longer throw\n\nThe three most-called methods now have non-throwing signatures.\nValidation intent is unchanged; only the Swift-side signalling\nmechanism is now idiomatic.\n\n```diff\n- try? cd?.track(\"paywall_seen\")            // v1.0.x — Swift required try?\n+ cd?.track(\"paywall_seen\")                 // v1.1.0 — clean call site\n\n- try? cd?.identify(userId: \"user_123\")     // v1.0.x\n+ cd?.identify(userId: \"user_123\")          // v1.1.0\n\n- try? cd?.reset()                          // v1.0.x\n+ cd?.reset()                               // v1.1.0\n```\n\nValidation failures (empty event name, empty userId, called after\n`stop()`) now:\n- Log a warning via `debugLogger` with a `*_dropped` key naming\n  the failure code.\n- Trigger `assertionFailure` in Debug builds — loud during dev,\n  silent no-op in Release. Aligns with Apple's first-party SDK\n  conventions (UserDefaults, URLSession, OSLog: none throw on\n  invalid arguments).\n- Skip the actual work — the call becomes a no-op.\n\n### Migration\n\nThis is a soft break. All v1.0.x callers still compile:\n- `try? cd.track(...)` → compiles with a \"no calls to throwing\n  functions\" warning. Drop the `try?` to clean up.\n- `try cd.track(...)` inside a `do/catch` → compiles but the\n  catch becomes unreachable (warning). Drop both `try` and\n  the catch.\n- Plain `cd.track(...)` (the v1.1.0 idiom) → compiles clean.\n\nThe non-throwing methods are:\n- `track(_:properties:)`\n- `identify(userId:email:traits:)`\n- `reset()`\n\nStill throwing (legitimate runtime failure modes):\n- `Crossdeck.start(options:)` — config validation\n- `identifyAndWait(userId:email:traits:)` — network round-trip + cdcust_ return\n- `forget()` — network round-trip\n- `getEntitlements()` — network round-trip\n- `syncPurchases(rail:...)` — network round-trip\n- `flush()`, `heartbeat()` — network round-trip\n\n### Cross-SDK consistency\n\nWeb/Node/RN's `track()` keep their throwing signature because in\nJavaScript, an uncaught throw propagates to the global error handler\nwithout requiring `try`/`catch` at every call site. The platform\ncontract is \"track validates input and signals failure for empty\nname\" — Swift's signalling is now language-idiomatic\n(`assertionFailure` + debug log) instead of `throws`."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.0.3",
    "date": "2026-05-25",
    "change": "patch",
    "anchor": "swift-1.0.3",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.0.3",
    "markdown": "Critical compile-fix release. v1.0.0–v1.0.2 declared `iOS(.v13)` in\n`Package.swift` but `defaultDebugLogger()` used Apple's modern\n`Logger` API, which is iOS 14 / macOS 11 / tvOS 14 / watchOS 7+.\nApps with a deployment target below those minimums failed to compile\nthe SDK with `'Logger' is only available in iOS 14.0 or newer`.\n\n### Fixed\n\n- `defaultDebugLogger()` now branches on availability. iOS 14+ uses\n  `Logger` with structured `privacy: .public` interpolation; older\n  OS versions fall back to the legacy `os_log` family (iOS 10+).\n  Signal vocabulary identical; Console.app filtering on the\n  `com.crossdeck.sdk` subsystem works on both paths.\n- Package now compiles against any deployment target ≥ iOS 13 —\n  same floor `Package.swift` has always claimed.\n\n### Notes\n\n- No API changes. Strictly additive availability gate."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.0.2",
    "date": "2026-05-25",
    "change": "patch",
    "anchor": "swift-1.0.2",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.0.2",
    "markdown": "Dogfood pass on the v1.0.1 surface. One additive API change to close\nthe biggest friction point a first-time Swift dev hit walking the\ninstall path; everything else is documentation / snippet polish that\nships on cross-deck.com.\n\n### Added\n\n- **`Crossdeck.current`** — process-singleton accessor. Returns the\n  most-recently-started client, or `nil` before `start` has succeeded\n  in this process / after the current client's `stop` is called.\n  Thread-safe via an `NSLock`; safe to read from any actor or queue.\n\n  ```swift\n  // Anywhere outside a SwiftUI view (services, view models,\n  // AppDelegate, Combine pipelines, background workers):\n  Crossdeck.current?.identify(userId: user.id, email: user.email)\n  Crossdeck.current?.track(\"paywall_seen\")\n  if Crossdeck.current?.isEntitled(\"pro\") == true { … }\n  ```\n\n  Inside SwiftUI views, keep using `@Environment(\\.crossdeck)` — it\n  participates in dependency tracking and is the idiomatic answer\n  for view bodies. The static accessor is for the 50% of the\n  codebase that isn't a View.\n\n  Bank-grade discipline: `stop()` clears the slot iff the stopped\n  instance is the one currently advertised, so concurrent\n  start+stop sequences on a second client never clobber the first\n  client's slot.\n\n### Changed\n\n- No behaviour changes. Public API is strictly additive — every\n  v1.0.1 caller continues to compile and behave identically."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.0.1",
    "date": "2026-05-25",
    "change": "patch",
    "anchor": "swift-1.0.1",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.0.1",
    "markdown": "KPMG/PwC-grade audit pass on the v1.0.0 surface. Every finding the\naudit flagged is closed in this release. Plus one critical\ncross-SDK canonical rename so the Swift identity surface matches\nthe Web/Node/RN role-model contract exactly.\n\n### Breaking — identity API renamed to match the platform contract\n\nThe v1.0.0 identify signature drifted from Web/Node/RN. v1.0.1\nrestores zero-drift parity. Migration is a one-line change:\n\n```diff\n- try? cd.identify(customerId: \"user_847\", traits: [\"email\": \"wes@example.com\", \"plan\": \"pro\"])\n+ try? cd.identify(userId: \"user_847\", email: \"wes@example.com\", traits: [\"plan\": \"pro\"])\n```\n\nSpecifically:\n\n- `customerId:` → `userId:`. The previous name collided with\n  `crossdeckCustomerId` (the cdcust_… canonical handle), confusing\n  the mental model. The Web/Node/RN SDKs all use `userId`.\n- `email` is now a first-class top-level argument. Previously it\n  was buried inside `traits` and missed the bank-grade\n  identity-merge that the Web SDK gets when email is shipped\n  separately. Now hoisted to the wire as `$email` on the\n  `$identify` event, matching Web/Node/RN.\n- Internal `customerId` field on `Identity` renamed to\n  `developerUserId` everywhere — the same name Web/Node/RN's\n  `Diagnostics.developerUserId` uses.\n- Wire event field renamed from `customer_id` to\n  `developer_user_id` (also matches what the backend ingest\n  expects).\n- `EntitlementSnapshot.customerId` → `developerUserId`.\n- `Identity.setCustomerIdSync(...)` → `setDeveloperUserIdSync(...)`.\n- Error code `missing_customer_id` → `missing_user_id`.\n\nThe Swift SDK doc now ships native auth-provider code blocks for\nSign In with Apple, Firebase Auth iOS, and Auth0 iOS, matching\nthe Web SDK doc's coverage of Firebase / NextAuth / Clerk /\nSupabase / Auth0 / custom backends.\n\n### Added — sync paywall reads\n\n- `Crossdeck.isEntitled(_:)` — synchronous bool check scoped to the\n  currently identified customer. Safe to call from SwiftUI bodies\n  and UIKit tap handlers. Never blocks on network.\n- `Crossdeck.entitlementsForCurrentCustomer()` — synchronous set\n  read. Returns nil if no customer is identified or the cache is\n  cold for them.\n- Internally backed by NSLock-protected mirror boxes on\n  `EntitlementCache`, `Identity`, `SuperProperties`, and\n  `ConsentManager`. Every actor mutation updates its sync mirror\n  atomically; reads acquire the lock only.\n\n### Fixed — bank-grade contract violations\n\n- **NSException handler now chains into the prior handler.**\n  Previous v1.0.0 overwrote the global handler, silently breaking\n  Crashlytics / Sentry / Bugsnag for any consumer who turned on\n  `captureUncaughtExceptions`. `ErrorCapture.install` now captures\n  `NSGetUncaughtExceptionHandler()` before registering ours and\n  invokes the prior handler after our snapshot.\n- **PII scrubber runs on `$error` events.** Previously the error\n  pipeline bypassed the scrubber — a `try?` that surfaced\n  `\"user jane@example.com not found\"` shipped raw. Now every\n  scrubbable field on the wire `$error` payload (message, stack\n  symbols, breadcrumb messages + data) is run through the\n  configured scrubber when `consent.scrubPII` is true.\n- **Breadcrumbs attached to `$error` events.** Previously collected\n  but dropped before enqueue. Now ship as\n  `error.breadcrumbs: [{timestamp_ms, category, level, message, data}]`\n  on the wire payload.\n- **`identify(...)` unconditionally clears the entitlement cache.**\n  Previous v1.0.0 only cleared on `didChange || priorId == nil`.\n  Now identifies always clear, matching the documented contract\n  that prevents stale entitlement leaks across customer switches.\n- **Self-request skip wired into `captureError(_:)`.** Errors whose\n  URL host matches the configured ingest endpoint are dropped\n  before processing — closes the feedback loop where a custom-\n  middleware-wrapped ingest failure would generate an `$error`\n  event that itself fails, ad infinitum.\n- **`track()` / `identify()` race fixed.** The pre-existing pattern\n  read identity inside a Task, racing concurrent identify Tasks.\n  Now reads identity synchronously on the caller's thread before\n  spawning the enqueue task. Deterministic ordering between\n  identify and a subsequent track.\n- **Empty key validation on super-properties.** `register(\"\", v)`\n  and `registerOnce(\"\", v)` previously wrote a null-key entry\n  that landed on every wire event; now silently rejected at the\n  boundary.\n\n### Fixed — privacy + correctness\n\n- **Errors-consent gate.** The error pipeline now honours\n  `consent.errors` — previously only the analytics pipeline\n  observed consent. Consumers can independently allow analytics\n  while denying error capture (or vice versa).\n- Removed dead code: stale `(anon, cust)` tuple in error capture\n  + unused NSRange in `scrubPII`.\n\n### Tests\n\n- **+19 new tests** (53 → 72 total). Coverage added for: sync\n  paywall reads from any thread, identify cache clearing under\n  same-id idempotent calls, identify cache clearing across\n  customer switches, `stop()` rejecting subsequent calls\n  (idempotent stop), URL-stub HTTP tests covering 2xx success,\n  4xx permanent (400/401/422), 5xx retryable, 408 retryable,\n  429 + Retry-After honoured, Idempotency-Key shipped verbatim\n  in the request header, User-Agent header carries SDK name +\n  version.\n\n### Notes\n\n- Public API is additive — every v1.0.0 caller still compiles.\n- The `Crossdeck` class remains `@unchecked Sendable` with a\n  detailed safety comment explaining the lock pattern."
  },
  {
    "sdk": "node",
    "sdkLabel": "Node",
    "version": "1.3.1",
    "date": "2026-05-24",
    "change": "patch",
    "anchor": "node-1.3.1",
    "install": "npm install @cross-deck/node@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/node",
    "github": "https://github.com/VistaApps-za/crossdeck-node/releases/tag/v1.3.1",
    "markdown": "Patch fix for the 1.3.0 dist-load contract. Mirrors the\n`@cross-deck/web@1.3.1` patch — `SDK_VERSION` is now sourced from a\ngenerated `src/_version.ts` file (produced by\n`scripts/sync-sdk-versions.mjs` from `package.json`) instead of a\nruntime `import { version } from \"../package.json\"` that needs a\n`with { type: \"json\" }` assertion to load as ESM. Wire contract is\nunchanged. 1.3.0 was never published to npm; 1.3.1 is the first\n1.3.x line to reach npm."
  },
  {
    "sdk": "node",
    "sdkLabel": "Node",
    "version": "1.3.0",
    "date": "2026-05-24",
    "change": "minor",
    "anchor": "node-1.3.0",
    "install": "npm install @cross-deck/node@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/node",
    "github": "https://github.com/VistaApps-za/crossdeck-node/releases/tag/v1.3.0",
    "markdown": "KPMG bank-grade audit closure. Six review batches landed five SDK PRs\nand a backend wiring fix that closes every P0 plus 12 of 13 P1 findings.\nNo public method renames; one internal contract change\n(`ErrorTracker.beforeSend` is now a getter) that also removes the\n`Object.defineProperty` workaround the node SDK shipped to compensate\nfor the same broken contract on web. Behavioural changes to the queue\nand the PII scrub strictly improve correctness. The wire\n`Crossdeck-Sdk-Version` header now reads from `package.json` so it\ncannot drift from the published bundle.\n\n### Fixed (P0)\n\n- **PII scrub sentinel tokens aligned with the backend.** `[email]` /\n  `[card]` → `<email>` / `<card>`, matching `backend/src/api/lib/scrub.ts`.\n  The same event scrubbed by SDK + backend now carries the same\n  sentinel — dashboard aggregation works again.\n- **`setErrorBeforeSend` contract cleaned up.** The\n  `ErrorTracker.beforeSend` field is now a getter\n  (`() => fn | null`). Removed the `Object.defineProperty` hack on\n  `tracker.opts` that worked around the old captured-by-value bug —\n  cleaner contract, lockstep with web.\n- **Event queue drops 4xx batches.** Pre-fix every `catch` triggered\n  `scheduleRetry` with the same `Idempotency-Key`. A 401 (key revoked),\n  400/422 (malformed batch), 403 (permission), 404 (wrong baseUrl)\n  spun the retry timer indefinitely while the backlog grew silently.\n  New `isPermanent4xx()` helper hard-stops on any 4xx EXCEPT 408 / 429\n  (transient by spec). On permanent failure: drop the batch, increment\n  `dropped`, fire `onPermanentFailure(info)`, emit\n  `queue.permanent_failure` on the EventEmitter, log via\n  `console.error` regardless of debug mode.\n- **Error-capture self-skip derived from `baseUrl`.** Pre-fix hardcoded\n  to `api.cross-deck.com`; customers on staging / regional / self-hosted\n  base URLs recursed (5xx → captureHttp → enqueue → /events →\n  captureHttp → ∞). Now strict-hostname compare against `selfHostname`\n  extracted from constructor `baseUrl`. Closes the substring-match\n  bypass (`api.cross-deck.com.attacker.example` would have matched).\n\n### Added\n\n- **`onPermanentFailure` callback** on `EventQueueConfig`, surfaced\n  via `CrossdeckServer.on(\"queue.permanent_failure\", …)` for host-app\n  paging.\n- **`sdk.flush_permanent_failure` debug signal** in the\n  `DebugSignal` vocabulary.\n\n### Changed\n\n- **`SDK_VERSION` is now imported from `package.json`.** The\n  `Crossdeck-Sdk-Version` header always matches the published bundle.\n  Single source of truth.\n- **Event ingest envelope now ships `environment`.** Pre-fix web sent\n  it and node didn't; backend `v1-events.ts` cross-checks it against\n  the API-key-derived env and rejects mismatches loudly\n  (`env_mismatch`). Defence-in-depth so a \"live key, env: sandbox\"\n  misconfig fails fast instead of polluting the wrong dashboard.\n- **`syncPurchases` body spread bug.** Pre-fix\n  `{ rail: input.rail ?? \"apple\", ...input }` — the `...input` ran\n  LAST and overrode the default when the caller passed\n  `rail: undefined` explicitly. Reversed: `{ ...input, rail }`.\n- **PII scrub regex uses `.replace()` unconditionally.** Dropped the\n  `.test()`-gating that carried `lastIndex` state between calls.\n- **`bootHeartbeat: false` no longer silences the\n  `sdk.no_durable_store` warning.** Pre-fix the warning lived inside\n  `emitBootTelemetry()` which sat inside the `bootHeartbeat` gate, so\n  the opt-out silenced the entire reason `entitlementStore` exists.\n  Split into two methods: `emitDurabilityWarning()` (local-only,\n  unconditional) and `emitBootTelemetryEvent()` (phone-home, still\n  gated).\n- **`isEntitled(string)` requires the `cdcust_` prefix** for canonical-\n  path resolution. Pre-fix any string with a cache entry resolved\n  through the canonical path — a small cross-tenant primitive if a\n  tenant's userId collided with another tenant's `crossdeckCustomerId`.\n  Non-prefixed strings now drop to alias lookup only.\n- **Self-skip applies to breadcrumbs too**, not just `captureHttp`.\n  Error reports no longer carry noisy `POST https://api.cross-deck.com/v1/events`\n  crumb entries.\n\n### Wiring (backend, paired)\n\n- **`v1-events` ingest now honours the per-project `piiAllowList`.**\n  The admin management surface (`v1-pii-allow-list.ts`) was persisted +\n  audit-logged but the hot ingest path never read it. The new\n  `backend/src/api/lib/pii-allow-list-cache.ts` (60s TTL,\n  single-flight) feeds the project's allow-list to `scrubProperties()`\n  on every batch. `HARD_LOCKED_PATTERNS` are always stripped from the\n  effective list regardless of what's in storage. (Backend-only —\n  listed here so server-SDK consumers know defence-in-depth is fully\n  closed.)"
  },
  {
    "sdk": "react-native",
    "sdkLabel": "React Native",
    "version": "1.0.0",
    "date": "2026-05-24",
    "change": "initial",
    "anchor": "react-native-1.0.0",
    "install": "npm install @cross-deck/react-native@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/react-native",
    "github": "https://github.com/VistaApps-za/crossdeck-react-native/releases/tag/v1.0.0",
    "markdown": "First public release. Built bank-grade from day one — every audit\npattern landed during the `@cross-deck/web` + `@cross-deck/node`\nKPMG review is baked in. Three Crossdeck pillars in one SDK,\nmodelled on the shipping `@cross-deck/web` API surface so a\ncross-platform team writes identical call-sites:\n\n```ts\nimport { Crossdeck } from \"@cross-deck/react-native\";\n\nCrossdeck.init({\n  appId: \"app_rn_xxx\",\n  publicKey: \"cd_pub_live_…\",\n  environment: \"production\",\n});\n\nawait Crossdeck.identify(\"user_847\");\nif (Crossdeck.isEntitled(\"pro\")) showPro();\nCrossdeck.track(\"paywall_shown\", { variant: \"v3\" });\n```\n\n### Subscriptions & entitlements\n\n- **Durable last-known-good entitlement cache.** `EntitlementCache.hydrate()`\n  loads from AsyncStorage during `init()`, so `isEntitled()` is correct\n  from the first call after `init()` resolves — no cold-start window\n  where a returning Pro customer reads as free.\n- **An outage can never fail a paying customer down to free.** A\n  failed `getEntitlements()` never clears the cache; only a successful\n  fetch replaces it. Each entitlement is still honoured against its\n  own `validUntil`, so a timed-out trial still ends.\n- **`onEntitlementsChange(listener)`** subscriber API for reactive UI\n  binding — fires after `getEntitlements()` / `syncPurchases()` /\n  `reset()`. Listener errors are swallowed (a buggy consumer can't\n  crash the SDK or other listeners) and counted in `diagnostics()`.\n- **`syncPurchases({ rail, signedTransactionInfo | purchaseToken })`**\n  forwards Apple StoreKit 2 or Google Billing evidence for backend\n  verification + entitlement projection.\n- **`isEntitled(key)`** + **`listEntitlements()`** are synchronous\n  reads of the in-memory cache. Subscribe via `onEntitlementsChange`\n  for reactive bindings.\n\n### Analytics\n\n- **Bank-grade event queue.** `pendingBatch` slot keeps the in-flight\n  batch with the SAME `Idempotency-Key` across retries (Stripe\n  pattern) — backend dedupe on `(projectId, eventId)` handles the\n  belt-and-suspenders. Persisted blob always carries\n  `[...pendingBatch, ...buffer]` via AsyncStorage so an app crash\n  mid-flight replays the in-flight batch on the next launch.\n- **4xx hard-stop.** 400 / 401 / 403 / 404 / 422 etc. drop the batch\n  loudly: `onPermanentFailure` callback + `console.error` regardless\n  of debug mode + `dropped` counter increments. Pre-fix (web/node\n  1.2.x and earlier) every error retried forever with the same key.\n- **Exponential backoff with full jitter** on retryable failures\n  (5xx / network / 408 / 429). Honours server `Retry-After` when\n  bigger than the computed window, capped at 24h as a sanity guard.\n- **Hard buffer cap (1000 events).** Past the cap we evict the\n  OLDEST events and increment `dropped` so the developer can see the\n  loss in `diagnostics()`.\n- **Super properties** (`register` / `unregister`) and **groups**\n  (`group(type, id, traits)`) — Mixpanel pattern, attached to every\n  event automatically. Both cleared on `reset()`.\n\n### Error capture\n\n- **`ErrorUtils.setGlobalHandler`** chains in front of RN's default\n  handler (the red-box developer overlay) so uncaught errors AND\n  unhandled promise rejections are captured WITHOUT breaking the\n  dev experience. Stack frames parsed via the Hermes / JSC / V8\n  unified parser.\n- **`globalThis.fetch` wrap** catches 5xx + network failures. The\n  configured `selfHostname` (derived from `init({ baseUrl })`) is\n  excluded so a Crossdeck-side outage doesn't recurse through its\n  own fetch-wrap. Strict hostname compare (no substring matches —\n  `api.cross-deck.com.attacker.example` doesn't falsely match).\n- **Per-fingerprint rate limit** (5 per minute by default) defends\n  against runaway loops. Per-session cap (100) bounds the worst\n  case.\n- **`captureError(err)` / `captureMessage(msg)`** manual API for\n  try/catch blocks + soft signals.\n- **`setErrorBeforeSend(hook)`** with the bank-grade getter contract\n  — a hook installed AFTER `init()` fires on the next captured\n  error. Pre-fix on web/node 1.2.x the hook was captured by value\n  and silently inert if installed late.\n- **Breadcrumb buffer (50 entries)** auto-populated by every\n  `track()` call + every `fetch` request (with the self-skip\n  filter). Attached to every error report.\n\n### Privacy & compliance\n\n- **PII scrub** — defensive regex pass over every string property\n  value before flush. Email-shaped → `<email>`, card-number-shaped\n  → `<card>` (sentinel tokens aligned with the backend so dashboard\n  aggregation works across SDK-scrub and backend-scrub paths).\n  **Recursive walk**: nested plain objects + arrays-of-objects are\n  visited, so a `{user:{email:\"x@y.com\"}}` payload ships scrubbed.\n- **`Crossdeck.consent({...})`** — three independent dimensions\n  (analytics / marketing / errors), each defaulting to `true`\n  (granted). `consent({analytics: false})` drops every subsequent\n  `track()` silently.\n- **`Crossdeck.forget()`** — GDPR / CCPA right to be forgotten.\n  Calls `/v1/identity/forget` + wipes every local state surface.\n\n### Diagnostics\n\n- **`Crossdeck.diagnostics()`** — stable shape whether or not\n  `init()` has been called. Returns identity (anonymousId,\n  crossdeckCustomerId, developerUserId), clock skew (server vs\n  client `Date.now()` at last heartbeat), entitlement cache\n  freshness, queue stats (buffered, dropped, in-flight, last error,\n  consecutive failures, next retry).\n- **Boot heartbeat** verifies the publishable key against the\n  Crossdeck API the moment the SDK is constructed. The dashboard's\n  \"Verify install\" check turns green within ~200ms without the\n  caller having to add an explicit call. Disable via\n  `autoHeartbeat: false` for CI / tests.\n\n### Cross-cutting\n\n- **`SDK_VERSION` codegen'd from `package.json`** via\n  `scripts/sync-sdk-versions.mjs` — the wire `Crossdeck-Sdk-Version`\n  header can never drift from the published bundle. CI gate via\n  `--check` mode catches drift before publish.\n- **Identity continuity via AsyncStorage** (optional peer dep) with\n  graceful in-memory fallback when AsyncStorage isn't installed\n  (Storybook snapshots, vitest under node).\n- **TypeScript-first** — strict mode, `noUncheckedIndexedAccess`,\n  every public type exported.\n\n### Coverage gaps explicitly deferred\n\n- **Auto-track sessions + deep-links** (AppState lifecycle + Linking\n  API) deferred to 1.1.0. v1.0 expects the developer to wire\n  `Crossdeck.track(\"screen.viewed\", {...})` from their nav lib's\n  listener. Adding AppState + Linking properly is its own design\n  decision (background-foreground policy, session timeout semantics,\n  cold-start vs warm-start distinction).\n- **Bundle-size budget gate** — RN apps don't have a per-byte CDN\n  cost the way web does; size discipline is a v1.1 add."
  },
  {
    "sdk": "swift",
    "sdkLabel": "Swift",
    "version": "1.0.0",
    "date": "2026-05-24",
    "change": "initial",
    "anchor": "swift-1.0.0",
    "install": "Xcode → File → Packages → Update to Latest Package Versions",
    "npm": null,
    "github": "https://github.com/VistaApps-za/crossdeck-swift/releases/tag/v1.0.0",
    "markdown": "Initial release. Brings the Crossdeck Swift SDK to the same\nbank-grade contract as the Web and Node SDKs.\n\n### Event ingestion\n\n- Durable, deduplicated, batched event queue. Pending batch lives in\n  a dedicated slot held across retries — a crash mid-flight does\n  NOT lose the batch; it rehydrates from `UserDefaults` on relaunch\n  and re-sends with the original `Idempotency-Key`.\n- 4xx hard stop. Permanent failures (`invalid_request_error`,\n  `authentication_error`, `permission_error`) drain through the\n  `onPermanentFailure` callback and never block newer events.\n- `Retry-After` honoured even above the local `maxMs`, clamped at\n  24h as a sanity ceiling against pathological server responses.\n- Buffer overflow drops OLDEST events, preserving the most-recent\n  diagnostic signal.\n\n### Error capture\n\n- Uncaught `NSException` handler installs an SDK-aware bridge.\n- Manual `captureError(...)` for handled errors. Both paths attach\n  a normalised stack + breadcrumb ring buffer.\n- `beforeSend` hook for per-error filter / mutation (return `nil`\n  to drop).\n- Self-request detection: HTTP failures against the SDK's own\n  ingest endpoint are skipped to prevent feedback loops.\n\n### Identity + entitlements\n\n- `anonymousId` persisted in `UserDefaults`, regenerated only on\n  `reset()`.\n- `identify(...)` unconditionally clears the entitlement cache so\n  a switched-customer never inherits the prior user's entitlements.\n- Entitlement cache scoped on `(customerId, entitlements)` — reads\n  for a different customer return `nil`, never the wrong set.\n\n### Privacy\n\n- PII scrubber on by default. `<email>` and `<card>` tokens (angle-\n  bracketed) match the platform-wide vocabulary across Web/Node/RN\n  and backend.\n- Recursive walk over nested dictionaries + arrays.\n- Default-deny consent state: both analytics and errors off until\n  the consumer opts in via `setConsent(...)`.\n\n### Concurrency\n\n- Strict concurrency enabled in `Package.swift`.\n- All shared mutable state lives behind Swift actors (`EventQueue`,\n  `Identity`, `EntitlementCache`, `SuperProperties`, `Breadcrumbs`,\n  `ConsentManager`).\n- All cross-actor types are `Sendable`.\n\n### Platforms\n\n- iOS 13+, iPadOS 13+, macOS 11+, tvOS 13+, watchOS 7+.\n- Zero runtime dependencies."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "1.3.0",
    "date": "2026-05-24",
    "change": "minor",
    "anchor": "web-1.3.0",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v1.3.0",
    "markdown": "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.\n\n### Fixed (P0)\n\n- **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).\n- **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.\n- **`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.\n- **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).\n- **`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.\n- **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).\n\n### Added\n\n- **`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.\n- **`onPermanentFailure({ status, droppedCount, lastError })`** is also exposed on the underlying `EventQueueConfig` for embedders wiring their own diagnostics surface.\n- **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.\n\n### Changed\n\n- **`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.\n- **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.\n- **`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.\n- **`reset()` clears the clock-skew snapshot.** `diagnostics().clock.skewMs` no longer echoes the prior session's skew after logout.\n- **`pageviewId` nulls on session boundary.** Pre-fix it survived 30-min idle resets and corrupted post-resume event → pageview correlation.\n- **`init()` re-entry tears down prior listeners** (`uninstallUnloadFlush`, autoTracker, webVitals, errors). Pre-fix duplicate `pagehide` / `beforeunload` / `visibilitychange` listeners accumulated across HMR / config-swap calls.\n- **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.\n- **`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.\n- **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.\n- **`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 }`.\n- **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.\n\n### Wiring (backend, paired)\n\n- **`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.)"
  },
  {
    "sdk": "node",
    "sdkLabel": "Node",
    "version": "1.2.0",
    "date": "2026-05-18",
    "change": "minor",
    "anchor": "node-1.2.0",
    "install": "npm install @cross-deck/node@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/node",
    "github": "https://github.com/VistaApps-za/crossdeck-node/releases/tag/v1.2.0",
    "markdown": "### Added\n\n- **Pluggable durable entitlement store (`entitlementStore`).** A new\n  constructor option taking an async `EntitlementStore` (a `load` /\n  `save` pair) — back it with Redis, your own database, or a KV. Every\n  successful `getEntitlements()` persists the result to it, and on a\n  network failure the SDK falls back to the stored snapshot. This is\n  what gives serverless deployments (Cloud Run / Lambda) cold-start\n  durability that an in-memory cache alone cannot. `EntitlementStore`\n  and `StoredEntitlements` are exported.\n- **Staleness fields in `diagnostics()`.** `entitlements.staleCustomers`,\n  `isStale`, `durableStore`, and `coldStartDurable` — so serving\n  last-known-good through a Crossdeck outage is observable, not silent.\n- **`sdk.no_durable_store` debug signal**, emitted once on a serverless\n  runtime with no `entitlementStore` configured, alongside a\n  `durability` fact on the boot telemetry event — so the cold-start gap\n  is measurable rather than a surprise in production.\n\n### Changed\n\n- **The entitlement cache is now durable last-known-good.**\n  `isEntitled()` and `list()` no longer expire to `false` / `[]` when\n  `entitlementCacheTtlMs` elapses — they keep serving the last\n  successfully-fetched entitlements. The TTL is now a refresh hint, not\n  an invalidation. Each entitlement is still honoured against its own\n  `validUntil`. A brief Crossdeck outage can no longer fail a paying\n  customer down to free 60 seconds after a warm."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "1.1.0",
    "date": "2026-05-18",
    "change": "minor",
    "anchor": "web-1.1.0",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v1.1.0",
    "markdown": "### Added\n\n- **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.\n- **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.\n\n### Changed\n\n- **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.\n- **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`."
  },
  {
    "sdk": "node",
    "sdkLabel": "Node",
    "version": "1.1.1",
    "date": "2026-05-14",
    "change": "patch",
    "anchor": "node-1.1.1",
    "install": "npm install @cross-deck/node@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/node",
    "github": "https://github.com/VistaApps-za/crossdeck-node/releases/tag/v1.1.1",
    "markdown": "### Changed\n\n- Ported the \"never silently surface an `Unknown` error\" hardening to\n  `@cross-deck/node` — a captured error with no usable type or message\n  is now labelled precisely instead of collapsing to `Unknown error`."
  },
  {
    "sdk": "cli",
    "sdkLabel": "CLI",
    "version": "1.1.1",
    "date": "2026-05-13",
    "change": "patch",
    "anchor": "cli-1.1.1",
    "install": "npm install -D @cross-deck/cli@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/cli",
    "github": null,
    "markdown": "### Fixed\n\n- Trailing-slash normalisation no longer corrupts Sentry sentinel\n  schemes. Before: `--url-prefix app:///` produced `app:/file.js` on\n  the wire because the naive `replace(/\\/+$/, \"\")` ate the empty-host\n  slashes. Now it preserves `scheme://[host]/` and only collapses\n  extras on the path part — `app:///` stays `app:///`, `https://x.com/a//`\n  becomes `https://x.com/a/`. Covered by 8 new test cases in\n  `normaliseUrlPrefix`."
  },
  {
    "sdk": "cli",
    "sdkLabel": "CLI",
    "version": "1.1.0",
    "date": "2026-05-13",
    "change": "minor",
    "anchor": "cli-1.1.0",
    "install": "npm install -D @cross-deck/cli@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/cli",
    "github": null,
    "markdown": "Dogfood-driven polish from installing the CLI against a real Crossdeck\nbackend project. Three customer-surfaced gaps, all closed.\n\n### Added\n\n- `crossdeck doctor` — Sentry/Stripe-pattern install diagnostic. Validates\n  auth token shape, derives environment from the key prefix, and proves\n  API reachability without uploading anything. The pre-flight check\n  customers should run once before wiring the CLI into CI.\n- `CROSSDECK_SECRET_KEY` env var as the canonical name for the auth\n  token. Matches every other Crossdeck SDK we publish.\n  `CROSSDECK_AUTH_TOKEN` is honoured as a back-compat alias for users\n  who set it during the v1.0.x window.\n- `--url-prefix` now accepts Sentry-style sentinel schemes for\n  non-browser bundles. `app:///` for server-side Node / Cloud\n  Functions / Lambda, `webpack://` for worker bundles, `capacitor://`\n  and `react-native://` for native shells. http(s) browser URLs\n  continue to work unchanged.\n\n### Changed\n\n- \"No maps found\" hint now lists TypeScript's `tsc` alongside Vite /\n  Webpack / ESBuild, and calls out the exact `tsconfig.json` settings\n  required (`\"sourceMap\": true, \"inlineSources\": true`). The previous\n  text mentioned bundler-only configurations.\n- `--url-prefix` validation error names the actual supported forms\n  (browser, server, native) instead of \"must be http(s)\"."
  },
  {
    "sdk": "node",
    "sdkLabel": "Node",
    "version": "1.1.0",
    "date": "2026-05-13",
    "change": "minor",
    "anchor": "node-1.1.0",
    "install": "npm install @cross-deck/node@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/node",
    "github": "https://github.com/VistaApps-za/crossdeck-node/releases/tag/v1.1.0",
    "markdown": "### Added\n\n- **Auto-heartbeat on construction.** `new CrossdeckServer({...})` now\n  fires a heartbeat in the background the moment the SDK is\n  constructed, fire-and-forget. The dashboard's row flips LIVE within\n  ~200 ms of the customer's process boot — no explicit `.heartbeat()`\n  call required in the bootstrap. Solves the cold-start serverless\n  verification problem at its root (function boot triggers SDK\n  construction triggers heartbeat; the install-verifier's URL probe\n  doubles as a cold-start waker).\n- New option `bootHeartbeat?: boolean` (default `true`). Set `false`\n  for latency-sensitive cold paths that want the prior v1.0.0\n  caller-controlled behaviour. Implicitly disabled in `testMode`.\n\n### Why this is non-breaking\n\nThe boot heartbeat is fire-and-forget and swallows its own errors —\nthe caller's code never blocks on it, never throws, and a failure\n(bad key, network blip, firewall) has zero effect on subsequent\nevent flushes. Equivalent to Sentry's `Sentry.init()` boot session."
  },
  {
    "sdk": "node",
    "sdkLabel": "Node",
    "version": "1.0.0",
    "date": "2026-05-13",
    "change": "major",
    "anchor": "node-1.0.0",
    "install": "npm install @cross-deck/node@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/node",
    "github": "https://github.com/VistaApps-za/crossdeck-node/releases/tag/v1.0.0",
    "markdown": "Full three-USP server SDK release. Version-aligned with `@cross-deck/web@1.0.0`. Bank-grade quality bar — Stripe + Apple + Google VP-level QA review across two passes. 6,796 LOC of source / 6,230 LOC of tests / 398 unit tests + 19 e2e todos passing / Gate 3 fixture verifying the snippet against the built bundle. Web-SDK parity at the capability level: every Web SDK guarantee that has a server-side analogue ships here.\n\n### Added — USP 1 (errors)\n\n- `server.captureError(err, options?)` — manual try/catch capture.\n- `server.captureMessage(msg, level?)` — non-error signals (Sentry pattern).\n- `server.setTag(key, value)` / `setTags(tags)` / `setContext(name, data)` / `addBreadcrumb(crumb)` / `setErrorBeforeSend(hook)`.\n- Auto-wired `process.on('uncaughtException')` + `process.on('unhandledRejection')` + `globalThis.fetch` wrap (5xx + network failures).\n- Stack-frame parsing (V8 + Firefox/Safari) with Node `in_app` heuristics for `node_modules/`, `node:`, `internal/`, `@cross-deck/node`.\n- Breadcrumb ring buffer (default 50 entries) attached to every error report.\n- djb2-fingerprinted grouping + per-fingerprint rate limit (default 5/min) + per-session cap (default 100). Fingerprint Map bounded at 4,096 with dead-entry prune + FIFO eviction.\n\n### Added — USP 2 (analytics)\n\n- Durable event queue: exponential backoff with full jitter, `Retry-After` honoured, **`Idempotency-Key` reused on retry of the same batch** (Stripe pattern).\n- `flush-on-exit` — `process.on('beforeExit')` + SIGTERM + SIGINT drain bounded by `flushOnExitTimeoutMs`. Critical for Lambda / Cloud Functions where the runtime freezes between invocations.\n- `server.register(properties)` / `server.unregister(key)` / `server.group(type, id, traits?)` — Mixpanel-style super-properties + group analytics.\n- `@cross-deck/node/auto-events` subpath:\n  - `crossdeckExpress(server, opts?)` + `crossdeckExpressErrorHandler(server, opts?)` (Express 4 + 5) — emits `request.handled` with route + method + statusCode + durationMs + userAgent + responseBytes. Captures uncaught route errors with request context.\n  - `wrapLambdaHandler(server, handler, opts?)` — emits `function.invoked` / `function.completed` / `function.failed` with cold-start detection, awaits `flush()` before return. Extracts `statusCode` + `responseBytes` for HTTP-style returns.\n  - `wrapFunction(server, handler, opts?)` — generic Firebase v1/v2 / Cloud Run wrap, shape-preserving.\n\n### Added — USP 3 (entitlements)\n\n- Per-customer TTL cache (default 60s) with **LRU eviction bounded at `maxCustomers` (default 10,000)** for long-running multi-tenant servers.\n- `server.isEntitled(hint, key)` — synchronous lookup after first warm. Accepts canonical `customerId` OR `IdentityHints` ({customerId, userId, anonymousId}).\n- `server.listEntitlements(hint)` — full snapshot.\n- `server.onEntitlementsChange(listener)` — subscribe to cache mutations.\n- `userId` / `anonymousId` → `crossdeckCustomerId` alias map (bounded at 10,000 with FIFO eviction).\n- `verifyWebhookSignature(payload, header, secret, options?)` — HMAC-SHA256 + constant-time compare + 5-min replay window + multi-secret rotation.\n- `signWebhookPayload(payload, secret, timestampSec)` — pure helper for fixture authors.\n\n### Added — cross-cutting\n\n- `runtime-info` detection for 13 platforms: AWS Lambda, Azure Functions, Google App Engine, Firebase Functions v1/v2, Cloud Run, Vercel, Netlify, Heroku, Render, Railway, Fly.io, generic Kubernetes, plain Node fallback. Auto-attached as `runtime.*` on every event + error.\n- `server.heartbeat()` — boot validation: `GET /sdk/heartbeat` returns project + app metadata, throws on auth failure.\n- `server.flush(): Promise<void>` — explicit drain.\n- `server.diagnostics()` — stable shape with `runtime` + `events` + `errors` + `entitlements` blocks.\n- `server.shutdown()` — teardown for tests + custom lifecycles. Clears super-properties, groups, cache, aliases, breadcrumbs, error state.\n- `scrubPii(value)` + `scrubPiiFromProperties(obj)` — opt-in PII regex utilities (email + card-number shapes).\n- `ConsoleDebugLogger` + `NullDebugLogger` — NorthStar §16 debug signal vocabulary.\n- `CrossdeckErrorCode` literal union derived from `CROSSDECK_ERROR_CODES` + `isCrossdeckErrorCode()` type guard for type-safe code comparisons.\n- `HeartbeatResponse` + `Diagnostics` + 30+ exported types.\n- `/auto-events` subpath in `package.json` exports.\n\n### Changed\n\n- **Breaking**: `track(event)` is now synchronous (returns `void`), enqueues for batched delivery, and auto-fills `anonymousId` with a process-stable `anon_node_…` when no identity hint is supplied. The old `await track(...)` shape is replaced by enqueue-and-flush.\n- `ingest(events[])` retains immediate-POST behaviour for bulk-import callers (no auto-fill, returns `IngestResponse`).\n- Secret key prefix in `diagnostics()` is now masked as `cd_sk_(test|live)_****<last4>` (Stripe pattern).\n\n### Added — QA review v2 (bank-grade SDK extras)\n\n- **Error subclass hierarchy** (Stripe pattern):\n  `CrossdeckAuthenticationError`, `CrossdeckPermissionError`,\n  `CrossdeckValidationError`, `CrossdeckRateLimitError`,\n  `CrossdeckNetworkError`, `CrossdeckInternalError`,\n  `CrossdeckConfigurationError`. All extend `CrossdeckError`. Pick the\n  right subclass via `makeCrossdeckError(payload)`. Constructed\n  automatically by `crossdeckErrorFromResponse()`.\n- `CrossdeckError.toJSON()` — structured-logger compatible\n  serialisation. Includes `type`, `code`, `requestId`, `status`,\n  `retryAfterMs`, `stack`. Critical for production observability with\n  Pino / Winston / DataDog.\n- `Crossdeck-Api-Version` header on every request, pinned to\n  `CROSSDECK_API_VERSION` constant. Forward-compat with backend\n  evolution (Stripe `Stripe-Version` pattern).\n- `User-Agent` header: `@cross-deck/node/<sdk> node/<node-version> <platform>`.\n  HTTP best practice. Override the runtime token via\n  `runtimeToken: \"bun/1.0\"` in options.\n- **Idempotent retry on GET methods** — default 3 attempts with\n  exponential backoff + full jitter, retrying on 408 + 5xx (except\n  501) and on network failures. Honours server `Retry-After`. POST\n  retries stay queue-driven (with batch-level `Idempotency-Key` reuse).\n  Configurable via `httpRetries: { maxAttempts, retryableStatuses }`.\n- `testMode: true` option — every HTTP call short-circuits to a\n  synthetic success response, no network goes out. Path-aware (returns\n  the right shape per endpoint). For caller test suites that don't\n  want to mock `globalThis.fetch`.\n- `onRequest` / `onResponse` hooks on `CrossdeckServerOptions`. Fire\n  on every request (including retries), carrying method, URL, status,\n  durationMs, attempt number. Synchronous, errors swallowed — telemetry\n  must never break the request pipeline.\n- **AbortSignal pass-through** on every async method. Final\n  `RequestOptions` argument with `{ signal, timeoutMs }`. Caller-aborted\n  requests throw `CrossdeckNetworkError({ code: \"request_aborted\" })`.\n  Composes with the per-request timeout — whichever fires first wins.\n- **CrossdeckServer extends EventEmitter** — typed `on` / `once` /\n  `off` / `emit` overloads via `CrossdeckServerEvents`. Events:\n  `queue.flush_succeeded`, `queue.flush_failed`, `queue.dropped`,\n  `queue.buffer_changed`, `error.captured`, `entitlements.warmed`,\n  `sdk.shutdown`.\n- **`Symbol.dispose` + `Symbol.asyncDispose`** — TC39 explicit\n  resource management. `using server = new CrossdeckServer(...)`\n  shuts down on scope exit; `await using` flushes first.\n- `server.isReady(): boolean` — synchronous readiness check.\n  `false` on sustained retry storm (≥ 5 consecutive failures) or\n  buffer pressure (≥ 80% of HARD_BUFFER_CAP).\n- `server.awaitReady(timeoutMs?, pollIntervalMs?): Promise<boolean>` —\n  backpressure-aware wait for ready state.\n- `server.getHealth()` — k8s-friendly snapshot: `ready`, `healthy`,\n  `bufferedEvents`, `inFlight`, `consecutiveFailures`, `lastFlushAt`,\n  `lastError`, `errorHandlersInstalled`.\n- `server.bulkGrantEntitlement(grants[])` + `bulkRevokeEntitlement(revokes[])` —\n  bounded-concurrency fan-out (default 5). Returns settled array;\n  partial failures preserved as `{ ok: false, error }` entries.\n\n### Notes\n\n- Bundle size: `dist/index.cjs` ~98 KB, `dist/auto-events/index.cjs` ~11 KB.\n- Zero runtime dependencies (`fetch` + `node:crypto` + `node:events` only).\n- 398 unit tests + 19 e2e todos passing. Source-to-test ratio ~100%.\n- Deferred to later releases: Fastify adapter (v0.3.0), Cloudflare Workers / Vercel Edge / Bun / Deno (v0.4+), OpenTelemetry / Pino / Winston log-capture integration (roadmap), HTTP keep-alive agent, request compression, SDK-level sampling."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "1.0.1",
    "date": "2026-05-13",
    "change": "patch",
    "anchor": "web-1.0.1",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v1.0.1",
    "markdown": "### Changed\n\n- **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.)"
  },
  {
    "sdk": "cli",
    "sdkLabel": "CLI",
    "version": "1.0.0",
    "date": "2026-05-12",
    "change": "initial",
    "anchor": "cli-1.0.0",
    "install": "npm install -D @cross-deck/cli@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/cli",
    "github": null,
    "markdown": "Initial release.\n\n### Added\n\n- `crossdeck upload-sourcemaps` command — discovers `.js + .map` pairs\n  in a build directory, batches them into ≤100-file chunks, uploads\n  to `/v1/releases/sourcemaps` with a `cd_sk_*` secret key.\n- Directory walker that honours the trailing `//# sourceMappingURL=`\n  comment for accurate `.js` ↔ `.map` pairing (Webpack, Vite, Rollup,\n  ESBuild, Next.js).\n- Edge-case handling: inline data-URI maps (skipped with hint),\n  missing companion `.map` files (skipped with hint), `?v=…` query\n  suffix on the source-map comment, `.mjs` and `.cjs` extensions,\n  nested asset directories, automatic `node_modules` exclusion.\n- Auth resolution via `CROSSDECK_AUTH_TOKEN` env var or\n  `--auth-token` flag. Publishable keys (`cd_pub_*`) rejected with\n  a clear error message.\n- Optional `CROSSDECK_PROJECT_ID` env var / `--project` flag — backend\n  infers project from the secret key but the flag lets multi-tenant\n  CI scripts assert which tenant they expect to hit.\n- Per-batch progress callback so CI logs surface upload progress\n  rather than blocking quietly.\n- Bank-grade error mapping: HTTP error responses become typed\n  `ApiError` with `status`, `code`, and `requestId` so customers can\n  correlate failures with backend logs.\n- 24 unit tests (discover, config, api-client). Coverage thresholds\n  enforced at 80%/80% statements/branches.\n- README with bundler-by-bundler setup guide, CI examples (GitHub\n  Actions, Vercel), exit-code reference, and privacy posture."
  },
  {
    "sdk": "node",
    "sdkLabel": "Node",
    "version": "0.1.0",
    "date": "2026-05-12",
    "change": "initial",
    "anchor": "node-0.1.0",
    "install": "npm install @cross-deck/node@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/node",
    "github": "https://github.com/VistaApps-za/crossdeck-node/releases/tag/v0.1.0",
    "markdown": "Initial server SDK release.\n\n### Added\n\n- Separate `@cross-deck/node` package with no browser assumptions.\n- `CrossdeckServer` constructor with secret-key validation.\n- Secret-key HTTP transport with typed `CrossdeckError` handling.\n- Web-parity sanitisation for traits and event properties, plus a transport\n  backstop that converts serialization failures into stable `CrossdeckError`s.\n- `identify()` / `aliasIdentity()` for server-side identity linking.\n- `forget()` for server-side GDPR/CCPA deletion requests.\n- `getEntitlements()` by `customerId`, `userId`, or `anonymousId`.\n- `getCustomerEntitlements(customerId)` server-only direct lookup route.\n- `track()` and `ingest()` for explicit server-side event ingest.\n- `syncPurchases()` for Apple signed purchase forwarding.\n- `grantEntitlement()` and `revokeEntitlement()` server-side manual overrides.\n- `getAuditEntry()` for server-side audit-log reads.\n- Dual ESM/CJS build.\n- Strict TypeScript + Vitest coverage for transport and public method routing."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "1.0.0",
    "date": "2026-05-11",
    "change": "major",
    "anchor": "web-1.0.0",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v1.0.0",
    "markdown": "**Error capture — the third pillar.** Closes the trio: analytics +\nrevenue/entitlements + errors all ship in one SDK. After this\nrelease the SDK covers every USP the platform sells. Bumped to\n`1.0.0` because every pillar is now in the box.\n\nBackwards-compatible: every Wave 1-4 API is unchanged. New error\nAPIs are additive. Source-compatible with 0.10.x — existing\n`Crossdeck.init({...})` callsites work exactly the same.\n\n### Added\n\n- **Automatic uncaught-error capture.** Global `window.onerror` listener\n  catches every uncaught synchronous error. Stack traces parsed into\n  normalised frames (Chrome / Firefox / Safari). Reported as\n  `error.unhandled` Crossdeck events through the same durable +\n  retried + idempotent queue as analytics.\n- **Automatic promise-rejection capture.** Global\n  `window.onunhandledrejection` listener catches unhandled async\n  failures. Reported as `error.unhandledrejection`.\n- **Automatic HTTP-failure capture.** `fetch()` and\n  `XMLHttpRequest` are wrapped to detect 5xx + network failures the\n  app code didn't catch. Reported as `error.http`. Crossdeck's own\n  API calls are explicitly excluded so a Crossdeck outage doesn't\n  self-amplify into the queue.\n- **`Crossdeck.captureError(err, { context, tags, level })`** — manual\n  capture from try/catch blocks. Sentry pattern.\n- **`Crossdeck.captureMessage(message, level)`** — non-error signals\n  (\"we hit the deprecated path\", \"soft warning\"). Reported as\n  `error.message`.\n- **`Crossdeck.setTag(key, value)` / `Crossdeck.setTags(tags)`** —\n  flat key/value labels attached to every subsequent error report.\n- **`Crossdeck.setContext(name, data)`** — structured named context\n  attached to every subsequent error report (Sentry pattern).\n- **`Crossdeck.addBreadcrumb(crumb)`** — custom breadcrumb for the\n  rolling buffer.\n- **`Crossdeck.setErrorBeforeSend(hook)`** — pre-send filter; return\n  null to drop, or a modified `CapturedError` to scrub fields. The\n  only way to redact app-specific PII (auth tokens in URLs, etc.)\n  before the report leaves the browser.\n- **Breadcrumb ring buffer.** Every analytics event auto-emits a\n  breadcrumb. The last 50 are attached to every error report so the\n  engineer reading the error sees exactly how the user got into the\n  broken state. Cleared on `reset()` / `forget()`.\n- **Fingerprinting.** Every error gets a stable 8-character hex\n  fingerprint (`djb2` of message + top 3 in-app frames). Dashboard\n  uses this to group identical errors so 1,000 occurrences of the\n  same bug show as 1 issue, not 1,000.\n- **Rate limiting.** Per fingerprint: max 5 reports per minute.\n  Defends against runaway loops (e.g. error in `setInterval`).\n  Hard session cap: 100 errors total. After that, capture stops\n  until the next session — the developer is told via Sentry\n  receiving \"1 unique error\" instead of \"1 million events\".\n- **Noise filtering.** Default `ignoreErrors` strips well-known\n  browser noise (`ResizeObserver loop limit exceeded`, `Script\n  error.`, etc.). Default `denyUrls` strips browser-extension\n  frames (`chrome-extension://`, `moz-extension://`, etc.).\n- **`autoTrack.errors: boolean`** flag (default true). Disable if\n  you have a separate error tracker (Sentry, Bugsnag) and don't\n  want duplicates.\n- **`consent.errors`** dimension (already in 0.10.0 for Web Vitals)\n  now ALSO gates error reporting. `consent({ errors: false })`\n  silently drops every error event.\n- **PII scrub** runs on every error payload (stack strings, URLs,\n  context blobs) before they leave the browser — same regex pass\n  as the analytics path.\n- **New error code** in `CROSSDECK_ERROR_CODES` for the\n  request_timeout / fetch_failed family already covered.\n- **47 new tests** (306 total, up from 260):\n    - `tests/breadcrumbs.test.ts` — 6 cases.\n    - `tests/stack-parser.test.ts` — 13 cases covering Chrome /\n      Firefox / Safari formats + in-app detection + fingerprinting.\n    - `tests/error-capture.test.ts` — 21 cases covering captureError,\n      captureMessage, filtering, rate limiting, sampling, beforeSend\n      hook, context/tags attachment, breadcrumb snapshot, consent\n      gating.\n    - `tests/crossdeck.test.ts` — 7 new integration cases.\n    - `tests/dist-loading.test.ts` — extended to assert the new\n      public methods exist on the built artefact.\n    - `e2e/smoke.spec.ts` — 5 new Playwright cases covering real-\n      browser error capture (manual captureError, uncaught\n      window.onerror, captureMessage, breadcrumb attachment,\n      consent gate).\n\n### Changed\n\n- **Bundle-size budgets bumped** to account for the new pillar:\n  core ESM / CJS / React / Vue from 28 KB → 32 KB; UMD from\n  16 KB → 18 KB. The full SDK now ships at ~30 KB gz —\n  comparable to Sentry's `@sentry/browser` *alone* (which doesn't\n  include analytics or revenue). All three pillars in one bundle.\n- `AutoTrackOptions` extended with `errors: boolean`.\n- `track()` now gates `error.*` events on `consent.errors` (in\n  addition to the existing `webvitals.*` gate); everything else\n  continues to gate on `consent.analytics`.\n\n### Compatibility\n\nSource-compatible with 0.10.x. No public API removed. The new error\ncapture is on by default — applications that already have Sentry\ninstalled should set `autoTrack: { errors: false }` to avoid\nduplicate reporting."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "0.10.0",
    "date": "2026-05-11",
    "change": "minor",
    "anchor": "web-0.10.0",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v0.10.0",
    "markdown": "**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.\n\n### Added\n\n- **`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.\n- **`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).\n- **`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.\n- **`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.\n- **`@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.\n- **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.\n- **`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.\n- **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.\n- **New debug signals:** `sdk.consent_changed`, `sdk.consent_denied`, `sdk.consent_dnt_applied`, `sdk.pii_scrubbed`.\n\n### Backend changes (paired)\n\n- **`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.\n- **`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.\n\n### Compatibility\n\nSource-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."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "0.9.0",
    "date": "2026-05-11",
    "change": "minor",
    "anchor": "web-0.9.0",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v0.9.0",
    "markdown": "**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.\n\n### Added\n\n- **`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.\n- **`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()`.\n- **`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.\n- **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.\n- **`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.\n- **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.\n\n### Changed\n\n- `IdentifyOptions` extended with `traits?: Record<string, unknown>`. Existing `email`-only callers unaffected.\n- `AutoTrackOptions` extended with `webVitals: boolean`. Existing `init()` callsites without `autoTrack` get it default-on.\n- `SessionAcquisition` extended with the six paid-traffic click-ID fields. Existing acquisition consumers unaffected — fields are empty strings when not present.\n\n### Compatibility\n\nSource-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."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "0.8.0",
    "date": "2026-05-11",
    "change": "minor",
    "anchor": "web-0.8.0",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v0.8.0",
    "markdown": "**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.\n\n### Added\n\n- **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).\n- **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`).\n- **`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.\n- **`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.\n- **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\" })`.\n- **Property validation at enqueue.** `track(name, properties)` now sanitises `properties` BEFORE the event lands in the queue. New module `event-validation.ts`. Behaviour:\n    - **Drops** functions, symbols, undefined values (with a debug warning).\n    - **Coerces** `Date` → ISO string, `BigInt` → string, `Error` → `{ name, message, stack }`, `Map` → plain object, `Set` → array.\n    - **Truncates** string values longer than `maxStringLength` (default 1024) with an ellipsis.\n    - **Replaces** circular refs with `\"[circular]\"` and depth > 5 nesting with `\"[depth-exceeded]\"`.\n    - **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.\n    - Caller's input is never mutated — sanitisation always produces a defensive copy.\n    - Output is guaranteed `JSON.stringify`-safe. One bad property can no longer poison the entire batch indefinitely.\n- **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.\n- **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.\n- **New debug signals:** `sdk.property_coerced`, `sdk.queue_persisted`, `sdk.queue_restored`, `sdk.flush_retry_scheduled`. Fire in debug mode only — quiet by default.\n- **65 new tests** (203 total, up from 138):\n    - `tests/event-validation.test.ts` — 19 cases covering every coercion / drop / truncation / depth / size-cap path + JSON-roundtrip + no-mutation guarantee.\n    - `tests/event-storage.test.ts` — 8 cases covering load / save round-trip, debouncing, malformed-blob recovery, version sentinel, throwing-storage degradation.\n    - `tests/retry-policy.test.ts` — 12 cases covering backoff math, jitter, Retry-After precedence, attempt overflow safety, counter reset.\n    - `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.\n    - `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`.\n    - `tests/errors.test.ts` — 9 new cases covering `parseRetryAfterHeader` for delta-seconds, HTTP-date, past dates, malformed input.\n    - `tests/entitlement-cache.test.ts` — 1 new case covering the listener-error counter.\n    - `tests/crossdeck.test.ts` — 1 new case asserting the full Wave-1 diagnostic surface.\n\n### Changed\n\n- `CrossdeckError` now carries an optional `retryAfterMs` field, populated from the response's `Retry-After` header on 4xx/5xx.\n- `Diagnostics` shape extended with:\n    - `clock: { lastServerTime, lastClientTime, skewMs }`\n    - `entitlements.listenerErrors: number`\n    - `events.consecutiveFailures: number`, `events.nextRetryAt: number | null`\n- Existing `Diagnostics` fields and their semantics are unchanged.\n\n### Migration\n\nNo 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."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "0.6.0",
    "date": "2026-05-10",
    "change": "minor",
    "anchor": "web-0.6.0",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v0.6.0",
    "markdown": "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.\n\n### Added\n\n- **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.\n- **`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.\n- **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.\n- **`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.\n- **`captureAcquisition()` exported** from `auto-track.ts` for unit testing acquisition extraction in isolation.\n- **18 new tests** (138 total, up from 120):\n    - `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`).\n    - `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.\n    - `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.\n\n### Server-side enrichment (lands without an SDK upgrade)\n\nThe 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:\n\n- **Geography** — `events.country` populated from the Cloudflare `CF-IPCountry` header at `/v1/events`. Server-decided, not client-trusted.\n- **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.\n- **Device hoist** — `events.browser`, `events.os`, `events.device_class` hoisted out of `properties_json` to first-class LowCardinality columns for fast slicing.\n- **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).\n- **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.\n- ClickHouse migration `006_analytics_columns.sql` is idempotent and additive — old rows already in `events` keep working with empty / 0 defaults.\n\n### Privacy posture\n\nPrivacy 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.\n\n### Compatibility\n\nSource-compatible with 0.5.0. No public API changes. No deprecated symbols. Existing snippets do not need to change."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "0.4.0",
    "date": "2026-05-09",
    "change": "minor",
    "anchor": "web-0.4.0",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v0.4.0",
    "markdown": "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.\n\n### Added\n\n- **`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.\n- **`@cross-deck/web/react` subpath export** — first-class React hooks built on top of the subscribe API:\n    - `useEntitlement(key): boolean` — re-renders the component the moment the cache mutates so a JSX snippet like `useEntitlement(\"pro\") && <ProBadge />` actually works.\n    - `useEntitlements(): readonly string[]` — reactive list of all active entitlement keys.\n  - 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.\n- **`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.\n- **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.\n\n### Why this exists\n\nWithout 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.\n\n### Build\n\n- `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.\n- React is now an optional peer dependency (`react >=18`).\n\n### Compatibility\n\nSource-compatible with 0.3.0. No breaking changes — `onEntitlementsChange` and the React hooks are purely additive."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "0.3.0",
    "date": "2026-05-08",
    "change": "minor",
    "anchor": "web-0.3.0",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v0.3.0",
    "markdown": "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`.\n\n### Added\n\n- **`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.\n- **`Crossdeck.flush()`** — alias of the old `flushEvents()`, matching the standardised name.\n- **`Crossdeck.syncPurchases(input)`** — replaces `purchaseApple`. Posts to `/v1/purchases/sync` and accepts an optional `rail` field for future Stripe/Google support.\n- **`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`).\n- **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.\n- **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`.\n\n### Changed\n\n- `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`.\n- `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.\n- The `not_started` configuration error code is now `not_initialized` to match the rename.\n\n### Removed\n\nNothing. 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."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "0.2.0",
    "date": "2026-05-06",
    "change": "minor",
    "anchor": "web-0.2.0",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v0.2.0",
    "markdown": "- Added auto-tracking: sessions, page views, and device-info enrichment are on by default in browsers. See `autoTrack` config to disable individually or wholesale.\n- Stable `Diagnostics` shape regardless of whether `start()` has been called — pre-start values are sensible empties."
  },
  {
    "sdk": "web",
    "sdkLabel": "Web",
    "version": "0.1.0",
    "date": "2026-05-05",
    "change": "initial",
    "anchor": "web-0.1.0",
    "install": "npm install @cross-deck/web@latest",
    "npm": "https://www.npmjs.com/package/@cross-deck/web",
    "github": "https://github.com/VistaApps-za/crossdeck-web/releases/tag/v0.1.0",
    "markdown": "Initial public release."
  }
]
