# Contracts — the structural guarantees Crossdeck enforces about itself

> Source: https://cross-deck.com/docs/contracts/ · Crossdeck developer docs (generated from the published page).

Architecture
Audience: senior engineer / CTO / procurement counsel
·
~22 min read
·
Updated May 27, 2026

Putting Crossdeck behind your paywall means trusting a third party with the question that pays your invoices: *is this customer entitled to what they paid for, and will the next release of the SDK still answer that question the same way?* A contract is the platform's structural answer. Each one is a single sentence we promise to keep true about the runtime — per-user cache isolation, cross-SDK idempotency-key determinism, error-envelope shape, lifecycle teardown completeness, payload schema-locks — paired with the source files that implement it and the tests that prove it on every release. This page is the full ledger. It is the evidence list a CTO or procurement counsel asks for before a paying app trusts us with its revenue, written so you can read every claim, click through to the code, and click through to the test that fails the day we break it.

## TL;DR

If you read nothing else.

Crossdeck publishes its structural guarantees as machine-readable JSON contracts under `contracts/`. Each contract is one claim about the runtime, the source paths that implement it, and the test paths that prove it. Per-SDK assertion tests run on every release and a CI workflow fails the build if a contract's tests are missing or the test names drift. The `CrossdeckContracts` registry is bundled inside every SDK — your own application can read the same ledger your auditor reads. Twenty contracts are enforced today across seven pillars; one is registered as `proposed` ahead of the feature shipping. The full table is at [§ ledger](#ledger).

- **Claim, code, test.** Each contract is one sentence of structural promise, a list of source files that implement it, and a list of tests that prove it. No prose-only invariants.

- **Pillars.** Seven groups today: Entitlements, Revenue, Errors, Lifecycle, Analytics, Webhooks, Diagnostics. New pillars get added when whole new surface areas land.

- **Statuses.** A contract is `enforced` (tests run on every release), `proposed` (registered before the feature ships, so the wire shape is locked first), or `retired` (kept for historical reasoning, no longer applicable).

- **Bundled into every SDK.** The full contract set is shipped as a JSON resource inside the SDK and exposed via the `CrossdeckContracts` registry. Your test harness can iterate the contracts your version of the SDK promises to honour.

- **Cross-SDK parity is CI-pinned.** The contracts that apply to more than one SDK (idempotency-key determinism, error-envelope shape, super-property merge precedence, …) carry per-SDK assertion tests that share a canonical vector. A drift on Web breaks the same test name on Swift.

- **Failure is reported on a one-way channel.** When a contract fires in a customer's CI, the SDK single-fires the diagnostic event to a Crossdeck reliability endpoint — never the customer's own appId, never their dashboard, never their event quota. Independent-controller flow per [Privacy Policy §6](https://cross-deck.com/legal/privacy/).

- **The ledger is auditable.** Schema-lock JSON is in the repo; the GitHub Actions logs are public per CI run; the SDK source is vendorable. Your auditor verifies the claim from primary evidence, not from this document.

## The trinity — claim, code, test

Most platform documentation describes runtime behaviour in prose and asks the reader to trust that the runtime matches. That is the wrong order. The product of a contract is not the prose; it is the matched triple of **a claim that's small enough to be true, source code that implements it, and a test that fails the day the source drifts**. Crossdeck's contracts are stored as JSON files where each carries exactly those three fields — plus pillar, status, and the SDK platforms it applies to.

The schema is fixed at `contracts/`:

```json
{
  "id": "per-user-cache-isolation",
  "pillar": "entitlements",
  "status": "enforced",
  "claim": "Every identify(userId) switches the entitlement cache to a physically separate per-user storage slot — `crossdeck:entitlements:<sha256(userId)>` — and unconditionally wipes the in-memory snapshot. A user-switch on a shared device CANNOT cross-read a prior user's cached entitlements, even if the in-memory clear is somehow skipped, because the storage keys are physically separate. reset() wipes every per-user slot via the persisted index.",
  "appliesTo": ["web", "react-native", "swift", "android"],
  "codeRef": [
    "sdks/web/src/entitlement-cache.ts",
    "sdks/swift/Sources/Crossdeck/EntitlementCache.swift",
    "..."
  ],
  "testRef": [
    {
      "file": "sdks/web/tests/entitlement-cache-isolation.test.ts",
      "name": "identify(B) makes A's entitlements unreachable from in-memory"
    },
    {
      "file": "sdks/swift/Tests/CrossdeckTests/EntitlementCacheIsolationTests.swift",
      "name": "test_identifyB_makesAEntitlementsUnreachable"
    }
  ],
  "registeredAt": "2026-05-26",
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0"
}
```

Every row in [the ledger below](#ledger) is one of these JSON files, rendered. The links in the *Code* column point at the source files; the links in the *Tests* column point at the test files plus the exact test name. The reader is invited to open both columns in parallel, see that the claim, the source, and the test agree, and walk away with primary-evidence confidence — not faith.

## The pillars

The contract set is organised into seven pillars, each grouping structural guarantees that share a runtime concern.

Pillar What it guards Contracts

Entitlements
The hot-path read every paywall depends on. Isolation between users, persistence across reloads, behaviour under outage.
1 enforced

Revenue
The money paths — purchases sync, idempotency, StoreKit transaction finishing, App Store account-token shape.
4 enforced

Errors
The wire vocabulary every SDK speaks back to the customer when something goes wrong — same envelope shape, same error codes, same human-readable resolutions.
3 enforced

Lifecycle
The runtime invariants of start / reset / stop / shutdown. Durable persistence across teardown, no observer leaks, no events lost when a process is going away.
4 enforced

Analytics
Cross-platform parity of the event pipeline. Same flush cadence, same property-merge precedence, same PII scrub posture, same funnel anchors.
5 enforced

Webhooks
HMAC verification, mandatory replay windows, distinguishable failure codes. Plus documentation honesty — what we ship today versus what is on the roadmap.
2 enforced · 1 proposed

Diagnostics
The shape of our own operational telemetry — schema-locked payloads, independent-controller flow, no end-user data on the wire.
1 enforced

## The status lifecycle

Contracts move through three states. The status is not decoration — it is what we are committing to.

Status What it means What we owe you

enforced
The claim is true at HEAD. Test references are real tests in CI; the contract-audit workflow blocks a merge if any test reference cannot be matched to a real test name in the repository.
If a contract is enforced, our CI cannot land a green merge that breaks it. A new SDK release ships only when every enforced contract's tests pass on every applicable platform.

proposed
The wire shape is locked before the feature ships. Used for forward-looking commitments — e.g. outbound webhook delivery — where we want the schema, retry policy, and event vocabulary fixed in the repository before any code arrives.
If a contract is proposed, the eventual enforced implementation will honour the registered shape. The customer-facing documentation says roadmap until the status flips.

retired
Kept for historical reasoning — the claim was once enforced and the platform no longer needs it (e.g. an SDK surface was removed). Retired contracts stay in the registry so old release notes resolve.
A retired contract makes no current promise. The registry's retired rows are out of scope for the CI gate.

## How enforcement works

The contract set is enforced at four layers. Each layer fails a different kind of drift.

### 1. The schema-lock JSON

Every contract is a JSON file under `contracts/ / .json`. A pre-commit and CI validator checks the shape: required fields present, `id` unique, `status` in the allowed set, `appliesTo` in the SDK enum, `codeRef` and `testRef` non-empty for any enforced contract. A pull request that mis-shapes a contract is blocked before review.

### 2. Per-SDK assertion tests

Every `testRef` entry must resolve to a real test in the repository. For TypeScript SDKs (Web / Node / React Native) the test name is matched against the Vitest test catalogue; for Swift the XCTest method name is matched; for Android the JUnit test method or backtick-quoted name is matched. The `contract-audit` GitHub Actions workflow fails the build if any reference does not resolve, or if a test name has drifted (renamed, removed, etc.) without an accompanying contract update.

### 3. Bundled runtime registry

Each SDK ships with the full contract set as a JSON resource inside the artefact and exposes it via `CrossdeckContracts`:

```ts
// Web / Node / React Native
import { CrossdeckContracts } from "@cross-deck/web";

for (const contract of CrossdeckContracts.all()) {
  if (contract.status !== "enforced") continue;
  console.log(`[crossdeck] ${contract.id} (${contract.pillar})`);
}

const isolation = CrossdeckContracts.byId("per-user-cache-isolation");
if (!isolation || isolation.status !== "enforced") {
  throw new Error("entitlement-cache isolation contract is not enforced");
}
```

```swift
// Swift
import Crossdeck

for contract in CrossdeckContracts.all() where contract.status == .enforced {
    print("[crossdeck] \(contract.id) (\(contract.pillar.rawValue))")
}

guard let isolation = CrossdeckContracts.byId("per-user-cache-isolation"),
      isolation.status == .enforced else {
    fatalError("entitlement-cache isolation contract is not enforced")
}
```

```kotlin
// Android
import com.crossdeck.CrossdeckContracts

for (contract in CrossdeckContracts.all()) {
    if (contract.status != ContractStatus.ENFORCED) continue
    println("[crossdeck] ${contract.id} (${contract.pillar.wire})")
}

val isolation = CrossdeckContracts.byId("per-user-cache-isolation")
check(isolation?.status == ContractStatus.ENFORCED) { "isolation contract is not enforced" }
```

The same generation script (`scripts/emit-contracts.mjs`) writes the bundle for every SDK from the canonical JSON, with the SDK's published version stamped into the bundle's `bundledIn` field. Drift between what we publish on the website and what your SDK believes is impossible by construction.

### 4. Customer-facing failure signal

When an enforced contract test fails — in our CI or a dogfood run — the SDK exposes a one-call helper to report the failure:

```ts
cd.reportContractFailure({
  contractId: "per-user-cache-isolation",
  failureReason: "expected isolation across user switch, got cross-read",
  runContext: process.env.CI ? "ci" : "dogfood",
  runId: process.env.GITHUB_RUN_ID ?? crypto.randomUUID(),
  testRef: {
    file: "tests/entitlement-cache-isolation.test.ts",
    name: "identify(B) makes A's entitlements unreachable from in-memory",
  },
});
```

This call fires the `crossdeck.contract_failed` event on a dedicated reliability channel — never the customer's own appId. The privacy framing is detailed at [§ privacy](#privacy); the wire shape is itself schema-locked by the [contract-failed-payload-schema-lock](#pillar-diagnostics) entry below.

## The failure-mode promise

A contract is only as useful as what happens the day it fires. Three environments, three different promises.

Environment What happens when an enforced contract fails

Our CI
The merge is blocked. Every PR runs the per-SDK test suite plus the contract-audit workflow. A failing enforced contract's test cannot reach main ; the SDK release pipeline reads from main exclusively.

Your CI
Your test fails through your own reporter (Vitest, XCTest, JUnit, Jest) — same way any failing test in your suite fails. The optional reportContractFailure(…) hook additionally fires crossdeck.contract_failed to our reliability endpoint, so we can spot a contract regression in your pinned SDK version across every customer running our SDK at the same release. Your dashboard never sees this event; it lands only in Crossdeck's operations workspace.

Your production app
SDK code paths that depend on a contract degrade tolerably rather than crash — isEntitled returns false if the cache is unreachable, track() persists to the durable queue if the network is offline, syncPurchases retries with the bounded exponential backoff documented in the revenue pillar. The runtime never throws an uncaught exception into your app because of an internal SDK contract violation; failures are surfaced as typed CrossdeckError values you can log or ignore.

## Runtime self-verification (v1.5.1+, Web SDK)

The contracts in the ledger above are tested in Crossdeck's CI on every release. The CI tests are necessary but not sufficient — they verify the structural guarantee held the moment we packaged the artefact, but they cannot verify the same guarantee continues to hold when a real customer cold-starts the SDK against a real browser's IndexedDB, a real device's network conditions, a real production identity-rotation sequence. The runtime self-verification layer closes that gap. Every Crossdeck SDK install actively tests its own contracts as it operates and reports failures back to Crossdeck's reliability workspace before the customer's own dashboard would have noticed. Available in the Web SDK at v1.5.1; ports to Node, React Native, Swift, and Android in the next patch release of each.

The platform-hardening signal.

Every install in the field tests its own structural contracts as it operates. PASS results stream to the customer's engineer's devtools console (when enabled — defaults dev=on, prod=off). FAIL results stream silently to Crossdeck's reliability workspace via a dedicated one-way channel that never touches the customer's own appId or dashboard. The Crossdeck operations team sees a contract regression in a customer's pinned SDK version on the day it first fires, not on the day the customer notices their paywall is wrong.

### The two layers

Layer When it runs What it does

Boot self-test
Once, on Crossdeck.init(...) .
Runs every applicable verifier against an isolated test context — the customer's real SDK state is never mutated. Prints a pass/fail summary to the console. Useful as the engineer-time proof that the contracts the SDK ships are honoured at runtime, not just in our CI.

Hot-path verifiers
Continuously, on every relevant SDK operation: every identify() , every syncPurchases() , every error-envelope parse, every track() .
Observes the operation the SDK just completed and asserts the contract claim held — that identify(B) actually rotated the entitlement-cache slot away from A , that the idempotency key derived for an Apple JWS matches the canonical cross-SDK vector, that the error envelope had the four required fields. PASS results emit at DEBUG level (cosmetic). FAIL results emit at WARN AND fire reportContractFailure(...) to the reliability channel.

The boot self-test is the loud, intentional verification a developer turns on during integration testing. Hot-path verifiers are the silent, always-on watchdogs that catch the real-world failures the boot test cannot — the network blip, the storage-quota exhaustion, the runtime that surprised us. Both layers report to the same reliability channel on failure; both honour the same three switches described below.

### Reading the console output

When `logVerifierResults` is enabled (default in development), the customer's devtools console streams the verifier results in real time. Boot self-test renders as a header + indented rows + summary; hot-path verifiers render one line per operation.

```text
// Boot self-test — prints once on Crossdeck.init()
[crossdeck] Contract self-verification — running 5 tests
   ✓ per-user-cache-isolation — slot rotated A:7c44…ee20 → B:a3f2…01b9 (isolated, physically separate) (3ms)
   ✓ idempotency-key-deterministic — apple JWS → a66b1640-efaf-bb4d-1261-6650033bf111 (canonical vector + determinism + rail isolation) (1ms)
   ✓ error-envelope-shape — { type, code, message, request_id } parsed and type ∈ ApiErrorType (0ms)
   ✓ flush-interval-parity — eventFlushIntervalMs = 2000ms (canonical default) (0ms)
   ✓ super-property-merge-precedence — caller > super > device verified (synthetic merge) (1ms)
[crossdeck] Self-verification passed — 5 passed, 0 failed (8ms)

// Hot-path verifiers — print as the SDK exercises each contract
[crossdeck.identify]     ✓ per-user-cache-isolation — slot rotated _anon → a3f2…01b9
[crossdeck.track]        ✓ super-property-merge-precedence — caller(2) > super(1) > device verified
[crossdeck.syncPurchases] ✓ idempotency-key-deterministic — apple → a66b1640-efaf-bb4d-1261-6650033bf111
[crossdeck.errorParse]   ✓ error-envelope-shape — invalid_request_error/missing_customer on 400
```

FAIL lines use the same shape with `✗` instead of `✓` and the `failureReason` in place of the evidence string:

```text
[crossdeck.identify] ✗ per-user-cache-isolation — in-memory snapshot still held entitlements after slot rotation (2ms)
```

When you see a FAIL line, three things have happened: the contract was violated for real (this is not a flake), Crossdeck's reliability workspace has just received a `crossdeck.contract_failed` event with the same `contract_id` + `failure_reason`, and Crossdeck's operations team will see it in their reliability dashboard the moment the event lands. Action on your side: open an issue, capture the conditions that produced it (browser / OS / SDK version), and reach out at [support@cross-deck.com](mailto:support@cross-deck.com). Crossdeck will be working on it from the moment the event arrives regardless.

### The three switches

Three independent flags on `CrossdeckOptions` govern the layer. Each docstring in the source explicitly names the other two as the wrong tool for the wrong job so no engineer can accidentally conflate them.

Flag Default What it controls

verifyContractsAtBoot
Development: true Production: false
Whether the boot self-test runs on Crossdeck.init(...) . Hot-path verifiers are unaffected. Opt-in for production by setting explicitly.

logVerifierResults
Development: true Production: false
Whether PASS results print to the console. Cosmetic only. FAIL results always print at WARN regardless of this flag. Failure reporting to Crossdeck's reliability channel is also unaffected.

disableContractAssertions
Always false
Kill-switch — disables the entire layer. No console output, no telemetry, no reliability-channel writes. The sovereignty escape hatch for enterprise customers whose posture forbids any outbound diagnostic telemetry to third-party controllers. See § sovereignty .

The three flags resolve with a strict precedence:

```text
code option (Crossdeck.init({...}))
     > dashboard remote config (per-app, see § dashboard)
     > DEBUG/RELEASE default
```

Code always wins so engineers retain ultimate control. Dashboard is the no-deploy operational lever for QA / staging soaks. Default is what ships when nobody touches anything. Routing is the same for every flag:

logVerifierResults: true
logVerifierResults: false

PASS (boot) console.info ✓ silent
PASS (hot_path) console.debug ✓ silent
FAIL (any) console.warn ✗ + reliability channel console.warn ✗ + reliability channel

`disableContractAssertions: true` short-circuits the entire matrix above — every layer is silent end-to-end.

### The dashboard toggle (per-app)

The **Apps** page in the dashboard ([/dashboard/apps/](https://app.cross-deck.com/apps)) exposes the same two flags as a per-app remote config that the SDK fetches on boot via `/v1/config`. Operationally, your QA / staging team can flip `Console output` to **Force on** for a single staging app, refresh the page, and watch the verifier stream in their devtools console — without changing any code, without redeploying, without coordinating with the engineering team.

Each toggle is tri-state:

- **Default** — clears the override. The SDK falls back to its DEBUG / RELEASE auto-detection.

- **Force on** — pins the flag `true` regardless of build environment.

- **Force off** — pins the flag `false` regardless of build environment.

`disableContractAssertions` is intentionally *not* exposed in the dashboard. It's the sovereignty kill-switch (§ sovereignty below); it must live in customer source so procurement / audit teams can grep for it.

Dashboard changes propagate to a customer's running SDK on the next `Crossdeck.init(...)` call, modulated by a 60-second edge cache on `/v1/config`. Practically: open a fresh browser session, see the new behaviour. The customer's own engineering team retains code-level override at all times — a `logVerifierResults: false` in `Crossdeck.init({...})` wins over a "Force on" in the dashboard.

### The sovereignty kill-switch

Some enterprise customers operate under data-sovereignty postures that forbid any outbound diagnostic telemetry to third-party controllers, including Crossdeck. For these customers, `disableContractAssertions: true` in `Crossdeck.init({...})` disables the entire verifier layer end-to-end: no verifiers run, no console output, no `reportContractFailure` writes, no `/v1/sdk/diagnostic` traffic.

This switch lives in customer source code by design, not in the dashboard. Procurement counsel and security audit teams who need to prove "no Crossdeck telemetry leaves our environment" can grep the customer's own repository for `disableContractAssertions` and read the exact code that disables the layer. A dashboard toggle for the same purpose would be unverifiable from outside the customer's source — the wrong tool for compliance evidence.

The runtime still behaves correctly with the kill-switch on.

Verifiers are observers, not assertions — the SDK's operational behaviour is unchanged when the layer is disabled. `identify()` still rotates the entitlement-cache slot, `syncPurchases()` still derives a deterministic idempotency key, `track()` still merges properties with the documented precedence. The only difference is that nobody is observing the operation and producing a result.

### For your auditor

The runtime layer is a third source of audit evidence, alongside the CI test suite and the JSON contract registry described in [§ Verifying this independently](#audit). Specifically:

- **Source.** The verifier framework + verifier implementations are in `sdks/web/src/_contract-verifiers.ts`. Every verifier is a small, auditable function — call `EntitlementCache.setUserKey("user_A")`, plant data, call `setUserKey("user_B")`, assert the storage suffixes are physically separate. The verifier reads the same internal SDK classes the SDK itself uses.

- **Tests.** The verifier framework + each verifier's happy + sad paths are tested in `sdks/web/tests/contract-verifiers.test.ts`. Routing matrix (PASS/FAIL × log-flag × kill-switch), re-entrancy guard, default-detection. Runs on every CI release.

- **Wire.** The failure-reporting path uses the same `_diagnostic-telemetry.ts` module documented in [Privacy Policy §6](https://cross-deck.com/legal/privacy/#sdk-diagnostic) — single-fire to the hardcoded reliability endpoint, schema-locked payload, IP-truncated at the edge, 7-day TTL. The runtime layer never produces a new wire shape; it produces the same `crossdeck.contract_failed` event the existing test-harness path already produces, with the addition of a `verification_phase` field set to `boot` or `hot_path`.

## Reliability-channel privacy

The `crossdeck.contract_failed` event carries operational telemetry to Crossdeck's reliability team. It is the single piece of customer-side data Crossdeck processes *as an independent controller*, not as a processor on your behalf. The lawful basis is legitimate interest in running a reliable SDK; that basis depends on the payload being structurally incapable of carrying end-user data. The schema-lock contract [contract-failed-payload-schema-lock](#pillar-diagnostics) enforces exactly that.

The full set of fields that may appear on the wire.

Required: `contract_id`, `sdk_version`, `sdk_platform`, `failure_reason` (categorical label, ≤128 chars), `run_context` (`ci` / `dogfood` / `customer-app`), `run_id`. Optional: `test_file`, `test_name`, `device_class` (categorical bucket like `simulator` / `phone` / `desktop`). Forbidden: `anonymousId`, `developerUserId`, `crossdeckCustomerId`, `email`, `ip`, `user_agent`, `message`, `stack`, `stack_trace`, `frames`, `exception_message`, `url`, `path`, `screen`, `title`, `label`, `text`, `ariaLabel`, `accessibilityLabel`, `contentDescription`, `session_id`, `sessionId`. The SDK strips forbidden keys before serialisation; the backend validator rejects them at the edge. See the schema-lock JSON.

- **Single-fire transport.** The SDK's `_DiagnosticTelemetry` module fires one POST to a hardcoded reliability endpoint (`https://api.cross-deck.com/v1/sdk/diagnostic`) with a hardcoded reliability publishable key embedded at SDK build time. The customer's HttpClient, queue, retry policy, and consent gate are NOT involved.

- **IP truncation at the edge.** The backend reduces the source IP to its network prefix (`/24` for IPv4, `/48` for IPv6) *before* any logging. The full IP never appears in our logs, our Firestore writes, our error reports. Anchored in [Breyer v Germany (CJEU C-582/14)](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A62014CJ0582): an IP received by a controller is personal data even if not persisted, so we minimise the footprint at receipt.

- **Separate collection, 7-day TTL.** Diagnostic writes land in `sdkDiagnostics/{eventId}` with a Firestore TTL policy of 7 days from receipt. Customer-facing event pipelines never read from this collection.

- **Auth gate.** Only the hardcoded reliability publishable key is accepted at `/v1/sdk/diagnostic`. A customer key — secret or publishable — 401s. The reliability key is shipped as a constant inside every SDK and as a constant in the backend validator; both can be diff'd against each other.

- **Independent-controller flow.** The processing is governed by [Privacy Policy §6](https://cross-deck.com/legal/privacy/#sdk-diagnostic), surfaced to customers' own privacy disclosures via the [Customer Disclosure Template](https://cross-deck.com/legal/customer-disclosure/#flow-b) ("Flow B"), and is the only one of Crossdeck's data-processing flows that is NOT covered by the customer-Crossdeck DPA.

## Binary stability of the API

The `CrossdeckContracts` registry and its `Contract` shape are public, typed surfaces on every SDK. The stability promise:

- **Patch releases** never remove fields, never rename fields, never change the shape of the registry. Adding a new contract entry is patch-safe.

- **Minor releases** can add new optional fields to the `Contract` shape, add new `pillar` or `status` enum values, or add new contracts. Existing callers continue to compile and run.

- **Major releases** are the only place a field can be removed or a required field added. We have not bumped the SDK major since v1.0.0 and have no plans inside the current planning window.

The registered `id` of a contract is a stable handle. Once registered, it is never reused for a different claim. If a contract changes shape (e.g. tightens from "Web only" to "every SDK"), the existing `id` stays; the `appliesTo` set widens; the `firstRegisteredIn` stays as the original release; the test references widen.

## The full contract ledger

Every contract in the registry, grouped by pillar. Each card shows the **claim**, the **code paths that implement it**, and the **test paths that prove it**. Links go to the file at the current `main` tip on GitHub.

### Entitlements Entitlements

The hot-path read every paywall depends on. One contract today; the surface is small on purpose.

### per-user-cache-isolation

enforced
web react-native swift android

Every `identify(userId)` switches the entitlement cache to a physically separate per-user storage slot — `crossdeck:entitlements: ` — and unconditionally wipes the in-memory snapshot. A user-switch on a shared device CANNOT cross-read a prior user's cached entitlements, even if the in-memory clear is somehow skipped, because the storage keys are physically separate. `reset()` wipes every per-user slot via the persisted index.

#### Code

- sdks/web/src/entitlement-cache.ts

- sdks/web/src/hash.ts

- sdks/react-native/src/entitlement-cache.ts

- sdks/swift/Sources/Crossdeck/EntitlementCache.swift

- sdks/swift/Sources/Crossdeck/IdempotencyKey.swift

- sdks/android/.../EntitlementCache.kt

- sdks/android/.../IdempotencyKey.kt

#### Tests

- web · entitlement-cache-isolation.test.ts identify(B) makes A's entitlements unreachable from in-memory

- web · entitlement-cache-isolation.test.ts clearAll() removes every per-user storage key plus the index

- web · entitlement-cache-isolation.test.ts a second cache instance reading A's storage suffix CANNOT see B's data

- rn · entitlement-cache-isolation.test.ts identify(B) makes A's entitlements unreachable from in-memory

- swift · EntitlementCacheIsolationTests.swift test_identifyB_makesAEntitlementsUnreachable

- swift · EntitlementCacheIsolationTests.swift test_identifiedWritesLandUnderPerUserSha256Key

- swift · EntitlementCacheIsolationTests.swift test_clearAll_removesEveryPerUserStorageKeyPlusIndex

- android · EntitlementCacheIsolationTest.kt identify B makes A entitlements unreachable from in-memory

- android · EntitlementCacheIsolationTest.kt clearAll removes every per-user storage key plus the index

- android · EntitlementCacheIsolationTest.kt a fresh cache bound to A's key CANNOT read B's blob

### Revenue Revenue

The money paths. Cross-rail idempotency, StoreKit transaction discipline, App Store account-token shape, typed billing errors.

### idempotency-key-deterministic

enforced
web node react-native swift android backend

`syncPurchases()` on every SDK derives a deterministic `Idempotency-Key` from the request body (UUID-shaped SHA-256 of `crossdeck:purchases/sync: : `). Same input → same key. Backend short-circuits same-key-same-body retries by returning the cached response (status + body) with `idempotent_replay: true` flag in the body AND `Idempotent-Replayed: true` response header. Same-key-different-body returns 400 `idempotency_key_in_use`. 24-hour TTL matches Stripe. Cache only stores 2xx responses — 4xx/5xx pass through so callers can fix bugs and retry. Cross-SDK parity is CI-pinned: `deriveForPurchase('apple', 'eyJ.jws.sig')` MUST equal `a66b1640-efaf-bb4d-1261-6650033bf111` on every SDK.

#### Code

- sdks/web/src/idempotency-key.ts

- sdks/react-native/src/idempotency-key.ts

- sdks/node/src/idempotency-key.ts

- sdks/swift/Sources/Crossdeck/IdempotencyKey.swift

- sdks/android/.../IdempotencyKey.kt

- backend/src/lib/idempotency-response-cache.ts

- backend/src/api/v1-purchases.ts

#### Tests

- web · idempotency-key.test.ts cross-SDK oracle — apple JWS pins canonical vector

- web · idempotency-key.test.ts is deterministic: same body twice → identical key

- web · idempotency-key.test.ts never silently falls back to a random key on missing identifier

- node · idempotency-key.test.ts apple JWS produces the canonical pinned UUID across all 5 SDKs

- swift · IdempotencyKeyTests.swift test_crossSdkOracle_appleJWS

- swift · IdempotencyKeyTests.swift test_railNamespacing_preventsCrossRailCollisions

- android · IdempotencyKeyTest.kt cross-SDK oracle for apple JWS

- android · IdempotencyKeyTest.kt missing identifier returns null — never silent random fallback

- backend · idempotency-response-cache.test.ts matches Stripe's 24-hour idempotency window

- backend · idempotency-response-cache.test.ts injects idempotent_replay: true into a JSON object body

### purchase-finish-iff-success

enforced
swift

Swift `PurchaseAutoTrack` calls `transaction.finish()` STRICTLY inside the success branch of `/purchases/sync`. A 5xx response leaves the StoreKit transaction unfinished so Apple's re-delivery on the next session keeps the purchase alive — mid-process-death plus a transient backend outage CANNOT silently lose revenue. Failures are persisted to `PendingPurchaseQueue` for bounded in-session retry (max 5, exp backoff 30s / 1m / 5m / 30m / 2h).

#### Code

- sdks/swift/.../PurchaseAutoTrack.swift

- sdks/swift/.../PendingPurchaseQueue.swift

#### Tests

- swift · PendingPurchaseQueueTests.swift test_shouldFinish_isTrueOnSuccess

- swift · PendingPurchaseQueueTests.swift test_shouldFinish_isFalseOnAnyFailure

- swift · PendingPurchaseQueueTests.swift test_recordFailure_persistsEntryWithBackoff

- swift · PendingPurchaseQueueTests.swift test_recordFailure_dropsEntryAtCap

### appaccounttoken-uuid-conformance

enforced
swift backend

Swift SDK derives `appAccountToken` from `developerUserId` as a proper RFC 4122 UUID (passthrough if id is already a UUID; else UUID v5 from URL namespace + `crossdeck: `; else omit). StoreKit's numeric `originalTransactionId` rides in its own dedicated wire field. Backend validator rejects non-UUID `appAccountToken` with 400.

#### Code

- sdks/swift/.../AppAccountTokenDerivation.swift

- sdks/swift/.../ServerEndpoints.swift

- backend/src/api/v1-purchases-validation.ts

#### Tests

- swift · AppAccountTokenDerivationTests.swift test_derive_returnsLowercaseUUIDDirectly_whenIdIsAlreadyUUID

- swift · AppAccountTokenDerivationTests.swift test_derive_derivesUUIDv5_whenIdIsNotUUID

- swift · AppAccountTokenDerivationTests.swift test_uuidV5_matchesRFCExample

- backend · v1-purchases-validation.test.ts rejects malformed appAccountToken

### billing-sync-typed-errors

enforced
android

Android Google Play billing sync surfaces a typed `CrossdeckError` on every non-2xx `/purchases/sync` outcome. The pre-1.4.0 silent debug-log swallow is forbidden in money-path code by founder principle 3 (no silent failures).

#### Code

- sdks/android/.../Crossdeck.kt

- sdks/android/.../BillingAutoTrack.kt

#### Tests

- android · BillingSyncOutcomeMapperTest.kt 400 permanent outcome with backend envelope propagates typed CrossdeckError

- android · BillingSyncOutcomeMapperTest.kt 5xx retryable outcome with backend envelope propagates typed CrossdeckError

### Errors Errors

The wire vocabulary every SDK speaks when something goes wrong — same envelope, same code catalogue, same human-readable resolutions.

### error-envelope-shape

enforced
web node react-native swift android backend

Every v1 REST endpoint returns errors in a Stripe-shape envelope: `{ error: { type, code, message, request_id } }` where `type` is one of `authentication_error` / `permission_error` / `invalid_request_error` / `rate_limit_error` / `internal_error` (the wire vocabulary in `backend/src/api/v1-errors.ts ApiErrorType`). HTTP status parity: `invalid_request_error` → 400, `authentication_error` → 401, `permission_error` → 403, `rate_limit_error` → 429, `internal_error` → 500. SDK-side clients parse via `crossdeckErrorFromResponse` (Web/Node/RN) / `crossdeckErrorFrom(response:)` (Swift) and surface the `request_id` verbatim so support traces are end-to-end joinable.

#### Code

- backend/src/api/v1-errors.ts

- sdks/web/src/errors.ts

- sdks/node/src/errors.ts

- sdks/react-native/src/errors.ts

- sdks/swift/Sources/Crossdeck/Errors.swift

- sdks/android/.../Errors.kt

#### Tests

- swift · ErrorsTests.swift test_errorEnvelope_fallsBackOnGarbageBody

- swift · ErrorsTests.swift test_errorEnvelope_reads_XRequestId_fallback

- android · ErrorTypeWireVocabTest.kt backend 500 response parses to INTERNAL_ERROR

### native-error-type-wire-vocab

enforced
swift android

Swift and Android SDK `CrossdeckErrorType` enums align with the backend's `ApiErrorType` wire vocabulary. New cases: `internalError` / `INTERNAL_ERROR` (matches backend's `internal_error` for 5xx) and `configurationError` / `CONFIGURATION_ERROR` (parity with TS SDKs). Deprecated cases `apiError` / `API_ERROR` and `unknown` / `UNKNOWN` are kept for source-compat but never emitted by new code paths — the backend NEVER sent those tokens.

#### Code

- sdks/swift/Sources/Crossdeck/Errors.swift

- sdks/android/.../Errors.kt

#### Tests

- android · ErrorTypeWireVocabTest.kt 5xx with no body falls back to INTERNAL_ERROR by status

- android · ErrorTypeWireVocabTest.kt fromWire(internal_error) returns INTERNAL_ERROR

- android · ErrorTypeWireVocabTest.kt fromWire(configuration_error) returns CONFIGURATION_ERROR

- android · ErrorTypeWireVocabTest.kt deprecated API_ERROR + UNKNOWN still parse from wire for source-compat

### sdk-error-codes-catalogue

enforced
web node

Web + Node SDK error-codes catalogues include EVERY backend-emitted `ApiErrorCode` with a description + resolution. Pre-v1.4.0 the catalogues documented codes the SDK threw ITSELF but ZERO backend codes — a developer hitting `invalid_api_key` / `origin_not_allowed` / `bundle_id_not_allowed` / `env_mismatch` / `idempotency_key_in_use` got `undefined` from `getErrorCode()`. v1.4.0 backfills the catalogue from `backend/src/api/v1-errors.ts` so every wire code has a canonical "what does this mean / what should I do" answer Stripe-style.

#### Code

- sdks/web/src/error-codes.ts

- sdks/node/src/error-codes.ts

- backend/src/api/v1-errors.ts

#### Tests

- web · error-codes-backfill.test.ts invalid_api_key resolution points at the dashboard

- web · error-codes-backfill.test.ts idempotency_key_in_use resolution mentions Stripe-grade contract

- web · error-codes-backfill.test.ts identity-lock codes carry permission_error type

- web · error-codes-backfill.test.ts no entry has an empty description or resolution

### Lifecycle Lifecycle

Start, reset, stop, shutdown. Durable persistence across teardown, no observer leaks, no events lost.

### init-reentry-drains-prior-queue

enforced
web react-native

Web + RN `init()` re-entry drains the prior `EventQueue`'s pending `setTimeout` BEFORE replacing `this.state`. Pre-v1.4.0 the teardown handled autoTracker / webVitals / errors / unloadFlush but NOT events, so the prior queue's timer would fire AFTER the state swap — sending old-init events against new-init http + identity references (cross-identity leak during HMR / config swap / multi-tenant SDK shells). The teardown CANNOT call `persistent.clear()` — the durable queue belongs to the SDK lifetime, not the `init()` lifetime, and a survived crash mid-flush re-hydrates on the next init.

#### Code

- sdks/web/src/crossdeck.ts

- sdks/react-native/src/crossdeck.ts

#### Tests

- web · init-reentry.test.ts re-init drains the prior queue's pending timer before swapping state

- web · init-reentry.test.ts re-init does NOT wipe the durable event store

### node-shutdown-awaits-flush

enforced
node

Node SDK's async `shutdown()` awaits the internal `flush()` before tearing down the queue. A queue with pending events at sync-shutdown time (`shutdownSync()` or `[Symbol.dispose]`) logs a `console.warn` with the dropped-event count — silent loss is incompatible with the bank-grade contract. `[Symbol.asyncDispose]` is equivalent to `await server.shutdown()`.

#### Code

- sdks/node/src/crossdeck-server.ts

#### Tests

- node · shutdown-flush.test.ts async shutdown() flushes queued events before clearing

- node · shutdown-flush.test.ts async shutdown() proceeds with teardown even if flush fails

- node · shutdown-flush.test.ts sync shutdownSync() warns when the buffer has events at teardown

- node · shutdown-flush.test.ts [Symbol.asyncDispose] equals await server.shutdown()

### swift-async-lifecycle

enforced
swift

Swift `Crossdeck.reset()` and `Crossdeck.stop()` are `async` so the caller knows when teardown is durably complete. `reset()` awaits identity / entitlements / super-properties / breadcrumbs clear AND flips an `isResetting` tombstone synchronously at entry so `isEntitled` returns false IMMEDIATELY across the clear window. `stop()` awaits `queue.persistAll()` AND cancels stored boot + heartbeat Tasks. Sync escape hatches `resetSync()` + `stopSync()` exist for callers that cannot await.

#### Code

- sdks/swift/Sources/Crossdeck/Crossdeck.swift

#### Tests

- swift · AsyncLifecycleTests.swift test_reset_tombstone_flipsBeforeAsyncCompletion

- swift · AsyncLifecycleTests.swift test_isEntitled_returnsFalseDuringResetWindow

- swift · AsyncLifecycleTests.swift test_stop_isAsync_andAwaitsDurablePersist

- swift · AsyncLifecycleTests.swift test_stopSync_runsTeardown_synchronously

- swift · AsyncLifecycleTests.swift test_stop_cancelsStoredBackgroundTasks

### swift-lifecycle-clean-teardown

enforced
swift

Swift `Crossdeck.stop()` deregisters every `NSNotificationCenter` observer it installed via `installLifecycleObservers` (`lifecycleObserverTokens` drained with `removeObserver`) AND calls `ErrorCapture.shared.uninstall()` so the global exception handler releases its references to the stopped client. Apple platform caveat: `NSSetUncaughtExceptionHandler` has no removal API; `uninstall()` restores the chained prior handler so exceptions continue reaching it post-stop().

#### Code

- sdks/swift/Sources/Crossdeck/Crossdeck.swift

- sdks/swift/Sources/Crossdeck/ErrorCapture.swift

#### Tests

- swift · LifecycleObserverCleanupTests.swift test_startedClient_capturesObserverTokens

- swift · LifecycleObserverCleanupTests.swift test_stop_clearsAllObserverTokens

- swift · LifecycleObserverCleanupTests.swift test_consecutiveLifecycle_doesNotAccumulateAcrossInstances

### Analytics Analytics

Cross-platform parity of the event pipeline. Same flush cadence, same property-merge precedence, same PII scrub posture, same funnel anchors.

### flush-interval-parity

enforced
web node react-native swift android

Every Crossdeck SDK defaults its event-queue flush interval to 2000ms — the Stripe-adjacent industry norm. Pre-v1.4.0 the defaults disagreed (Web/Node 1500ms; RN/Swift/Android 5000ms), so cross-platform funnels saw events landing at different cadences. Per-instance override stays — call sites can still tune it freely.

#### Code

- sdks/web/src/crossdeck.ts

- sdks/node/src/crossdeck-server.ts

- sdks/react-native/src/crossdeck.ts

- sdks/swift/Sources/Crossdeck/EventQueue.swift

- sdks/android/.../EventQueue.kt

#### Code-pinned defaults (no test names; the constant IS the contract)

- swift · EventQueue.swift — flushIntervalMs: Int = 2_000

- android · EventQueue.kt — flushIntervalMs: Long = 2_000L

- web · crossdeck.ts — options.eventFlushIntervalMs ?? 2000

- node · crossdeck-server.ts — options.eventFlushIntervalMs ?? 2000

- rn · crossdeck.ts — options.eventFlushIntervalMs ?? 2000

### super-property-merge-precedence

enforced
swift

Every Crossdeck SDK merges event properties with the precedence **device

#### Code

- sdks/swift/.../EventPropertyMerge.swift

- sdks/swift/.../Crossdeck.swift

#### Tests

- swift · EventPropertyMergeTests.swift test_super_overrides_device

- swift · EventPropertyMergeTests.swift test_caller_overrides_super

- swift · EventPropertyMergeTests.swift test_full_precedence_chain

- swift · EventPropertyMergeTests.swift test_matchesWebNodeRNPrecedence

### node-pii-scrubber

enforced
node

Node SDK's `track()` applies `scrubPiiFromProperties` on the enqueue path — parity with Web / RN / Swift. Pre-v1.4.0 the Node SDK was the ONLY one that skipped this, shipping every `track()` payload UNREDACTED despite the README promising parity. `CrossdeckServerOptions.scrubPii` defaults to `true`; explicit `false` opts out for regulator-required audit trails with a documented blast-radius warning.

#### Code

- sdks/node/src/crossdeck-server.ts

- sdks/node/src/types.ts

- sdks/node/src/consent.ts

#### Tests

- node · track-pii-scrub.test.ts by default redacts email-shaped values to

- node · track-pii-scrub.test.ts redacts card-number-shaped values to

- node · track-pii-scrub.test.ts walks nested maps + arrays

- node · track-pii-scrub.test.ts scrubPii: false preserves the raw payload (opt-out)

- node · track-pii-scrub.test.ts scrubPii: true is the default when option is omitted

### rn-session-id-enrichment

enforced
react-native

RN SDK's `track()` pipeline attaches a `sessionId` property to every event when the host has called `setSessionId(...)` — parity with the web SDK's session-anchored funnel queries. Pre-v1.4.0 the enrichment merged device + super + groups + caller but never carried `sessionId`, so cross-platform funnels on session anchors returned zero RN rows. The host owns session lifecycle (AppState + nav library); the SDK exposes `setSessionId()` / `setSessionId(null)` for the host to drive. Caller-supplied `sessionId` in properties still wins on conflict (matches the caller > super > device precedence chain).

#### Code

- sdks/react-native/src/crossdeck.ts

#### Tests

- rn · session-id-enrichment.test.ts track() events carry sessionId after setSessionId() is called

- rn · session-id-enrichment.test.ts track() events do NOT carry sessionId before setSessionId() is called

- rn · session-id-enrichment.test.ts setSessionId(null) clears the active session

- rn · session-id-enrichment.test.ts caller-supplied sessionId property overrides setSessionId() value

### sync-purchases-funnel-parity

enforced
web node react-native swift android

Manual `syncPurchases()` emits a `purchase.completed` analytics event on success across ALL SDKs. Pre-v1.4.0 only Swift / Android auto-track emitted it — Web / Node / RN manual calls + Swift / Android manual calls fired ZERO analytics. Schema mirrors the auto-track event name + rail / productId / subscriptionId so cross-platform funnels reconcile on every payment path. When the backend short-circuits via the v1.4.0 idempotency cache, the event also carries `idempotent_replay: true`.

#### Code

- sdks/web/src/crossdeck.ts

- sdks/node/src/crossdeck-server.ts

- sdks/react-native/src/crossdeck.ts

- sdks/swift/.../Crossdeck.swift

- sdks/android/.../Crossdeck.kt

#### Tests

- web · sync-purchases-funnel.test.ts emits purchase.completed after a successful sync

- web · sync-purchases-funnel.test.ts carries idempotent_replay=true when backend replied from cache

### Webhooks Webhooks

HMAC verification, mandatory replay windows, distinguishable failure codes. One `proposed` entry that locks in outbound delivery's shape before the feature ships.

### verifier-timestamp-mandatory

enforced
node

Node `verifyWebhookSignature()` enforces a MANDATORY timestamp window. Pre-v1.4.0 the helper silently disabled replay protection on `tolerance=0` and on `Infinity` / `NaN` / `null`. v1.4.0 rejects non-finite / negative / above-24h-cap tolerances at the boundary with typed `webhook_invalid_tolerance` and always runs the drift check. Verification failures are surfaced via distinguishable codes: `webhook_signature_mismatch` (wrong-secret signal), `webhook_timestamp_outside_tolerance` (replay-attack signal — alert separately), `webhook_timestamp_missing`, `webhook_payload_not_json`, `webhook_missing_secret`, `webhook_invalid_tolerance`.

#### Code

- sdks/node/src/webhooks.ts

- sdks/node/src/error-codes.ts

#### Tests

- node · webhooks.test.ts tolerance of 0 still enforces the replay window (v1.4.0 — cannot disable)

- node · webhooks.test.ts rejects Infinity tolerance (would silently disable replay protection)

- node · webhooks.test.ts rejects NaN tolerance

- node · webhooks.test.ts rejects negative tolerance

- node · webhooks.test.ts rejects tolerance above the 24h cap

- node · webhooks.test.ts rejects non-number tolerance (null / string)

- node · webhooks.test.ts accepts tolerance exactly at the 24h cap

- node · webhooks.test.ts malformed header (no t= or no v1=) throws webhook_timestamp_missing

- node · webhooks.test.ts valid signature but non-JSON payload throws webhook_payload_not_json

### documentation-honesty

enforced
node backend

Customer-facing documentation honestly tags outbound webhook delivery as ROADMAP (no signer, no worker, no scheduler in `backend/src` yet). The Node verifier helper exists today for fixture authoring + locking the validation contract surface BEFORE delivery ships — its jsdoc carries an explicit `[ROADMAP]` disclaimer so a developer reading the source doesn't assume Crossdeck sends webhooks today. The [rail-webhooks doc](https://cross-deck.com/docs/rail-webhooks/) no longer claims state surfaces "through the dashboard, SDKs, and outbound webhooks" — outbound is gated to the explicit roadmap section.

#### Code

- sdks/node/src/webhooks.ts

- docs/rail-webhooks/index.html

- docs/webhooks-receive/index.html

#### String matches

- node · webhooks.ts — [ROADMAP — v1.4.0 honesty note]

- docs · rail-webhooks/index.html — Outbound push-to-your-backend webhooks are **roadmap**

- docs · webhooks-receive/index.html — This feature is on the roadmap

### outbound-delivery-roadmap

proposed
backend

Outbound webhook delivery contracts — locked in BEFORE delivery ships. When the signer + worker + scheduler + dead-letter dashboard surface land, this contract enforces: HMAC-SHA256 + timestamp signing (Stripe-compatible), exponential-backoff retry policy with 4xx hard-stop, 5-minute default replay window (configurable up to 24h via the same timestamp-validation helper), at-least-once delivery via persistent worker queue, dead-letter visibility in the dashboard for events that exhaust retries, and a stable event vocabulary (`subscription.created` / `subscription.updated` / `subscription.canceled` / `customer.created` / `entitlement.granted` / `entitlement.revoked` / etc.) documented in the dashboard schema before customers wire handlers.

#### Forward-looking code references

- sdks/node/src/webhooks.ts (the verifier shape that the eventual sender will sign against)

- docs/webhooks-receive/index.html

#### Forward-looking tests

- node · webhooks.test.ts tolerance of 0 still enforces the replay window (v1.4.0 — cannot disable)

- node · webhooks.test.ts rejects Infinity tolerance (would silently disable replay protection)

### Diagnostics Diagnostics

The shape of *our own* operational telemetry — the schema-lock that backs the independent-controller lawful basis.

### contract-failed-payload-schema-lock

enforced
web node swift android react-native

The `crossdeck.contract_failed` event payload contains ONLY the named diagnostic fields and never any end-user personal data. The wire shape is fixed — adding a new field requires (1) a pull request that updates this contract's `allowedFields` set, (2) a Privacy Policy §6 amendment, and (3) the [Customer Disclosure Template](https://cross-deck.com/legal/customer-disclosure/) / [SDK Data Collection Reference](https://cross-deck.com/legal/sdk-data/) §B updates. Per-SDK assertion tests enforce the field set on every release. This is the structural guarantee that backs the independent-controller lawful basis in the Privacy Policy: the payload remains diagnostic-only, not personal, so the legitimate-interest analysis stays valid as the SDK evolves.

#### Code

- contracts/diagnostics/contract-failed-payload-schema-lock.json

- backend/src/api/v1-sdk-diagnostic.ts

- sdks/web/src/_diagnostic-telemetry.ts

- sdks/node/src/_diagnostic-telemetry.ts

- sdks/react-native/src/_diagnostic-telemetry.ts

- sdks/swift/.../_DiagnosticTelemetry.swift

- sdks/android/.../_DiagnosticTelemetry.kt

#### Tests

- web · contract-failed-schema-lock.test.ts reportContractFailure payload conforms to schema-lock

- node · contract-failed-schema-lock.test.ts reportContractFailure payload conforms to schema-lock

- swift · ContractFailedSchemaLockTests.swift test_reportContractFailure_payloadFieldsAreInAllowList

- swift · ContractFailedSchemaLockTests.swift test_reportContractFailure_doesNotEnterCustomerTrackPipeline

- android · ContractFailedSchemaLockTest.kt reportContractFailure payload conforms to schema-lock

- android · ContractFailedSchemaLockTest.kt reportContractFailure does not enter customer track pipeline

- rn · contract-failed-schema-lock.test.ts reportContractFailure payload conforms to schema-lock

- backend · v1-sdk-diagnostic.test.ts rejects payloads containing forbidden fields

- backend · v1-sdk-diagnostic.test.ts rejects writes from non-reliability publishable keys

- backend · v1-sdk-diagnostic.test.ts truncates source IP to network prefix before any logging

## Verifying this independently

If you or your auditor want to verify the platform's contract posture without relying on this page, the primary evidence is in three places.

- **The JSON.** The canonical source of every contract is the set of JSON files under `contracts/`. The schema is fixed; every field on this page is either derived from one of these files or is meta-content. Diffing the directory between two SDK releases tells you exactly which structural guarantees changed.

- **The CI runs.** Every push to `main` runs three workflows: `Test` executes the per-SDK suites; `Contract Audit` matches every `testRef` name against the real test catalogue; `Deploy Functions` publishes the backend that serves the reliability endpoint. The logs are publicly readable per run.

- **The SDK source.** Every SDK is published to its public GitHub mirror ([crossdeck-web](https://github.com/Crossdeckhq/crossdeck-web), [crossdeck-node](https://github.com/Crossdeckhq/crossdeck-node), [crossdeck-react-native](https://github.com/Crossdeckhq/crossdeck-react-native), [crossdeck-swift](https://github.com/Crossdeckhq/crossdeck-swift), [crossdeck-android](https://github.com/Crossdeckhq/crossdeck-android)) and to the corresponding registry (npm, Swift Package Index, Maven Central). The runtime registry inside each artefact reads the same JSON that ships in the monorepo.

## The SDK surface

Two API surfaces. `CrossdeckContracts` for reading the registry; `reportContractFailure(...)` for emitting the failure event.

### Reading the registry

```ts
// Web / Node / React Native
import { CrossdeckContracts, type Contract } from "@cross-deck/web";

const all: readonly Contract[] = CrossdeckContracts.all();
const enforced: readonly Contract[] = all.filter((c) => c.status === "enforced");

const isolation: Contract | undefined =
  CrossdeckContracts.byId("per-user-cache-isolation");

const matchingTest: Contract | undefined =
  CrossdeckContracts.findByTestName("identify(B) makes A's entitlements unreachable from in-memory");

console.log("[crossdeck] bundled in", CrossdeckContracts.bundledIn);
console.log("[crossdeck] sdk version", CrossdeckContracts.sdkVersion);
```

```swift
// Swift
import Crossdeck

let all: [Contract] = CrossdeckContracts.allIncludingHistorical()
let enforced: [Contract] = CrossdeckContracts.all()

if let isolation = CrossdeckContracts.byId("per-user-cache-isolation") {
    assert(isolation.status == .enforced)
}

if let contract = CrossdeckContracts.findByTestName("test_identifyB_makesAEntitlementsUnreachable") {
    print(contract.id, contract.pillar)
}
```

```kotlin
// Android
import com.crossdeck.CrossdeckContracts
import com.crossdeck.ContractStatus

val enforced = CrossdeckContracts.all()
val isolation = CrossdeckContracts.byId("per-user-cache-isolation")
check(isolation?.status == ContractStatus.ENFORCED)
```

### Reporting a failure

The helper is exposed on every SDK. The same shape, the same fields. The wire shape is schema-locked at [contract-failed-payload-schema-lock](#ctr-contract-failed-payload-schema-lock).

```ts
// Vitest — typically in tests/_setup.ts
import { afterEach } from "vitest";
import { Crossdeck, CrossdeckContracts } from "@cross-deck/web";

afterEach((context) => {
  if (context.task.result?.state !== "fail") return;
  const contract = CrossdeckContracts.findByTestName(context.task.name);
  if (!contract) return;
  Crossdeck.reportContractFailure({
    contractId: contract.id,
    failureReason: context.task.result.errors?.[0]?.message ?? "unknown",
    runContext: process.env.CI ? "ci" : "dogfood",
    runId: process.env.GITHUB_RUN_ID ?? "local-" + Date.now(),
    testRef: { file: context.task.file?.filepath ?? "?", name: context.task.name },
  });
});
```

```swift
// XCTest — install an XCTestObservation in Tests/setUp
import XCTest

final class ContractFailureReporter: NSObject, XCTestObservation {
    func testCase(_ testCase: XCTestCase, didRecord issue: XCTIssue) {
        guard let contract = CrossdeckContracts.findByTestName(testCase.name) else { return }
        Crossdeck.shared.reportContractFailure(.init(
            contractId: contract.id,
            failureReason: issue.compactDescription,
            runContext: ProcessInfo.processInfo.environment["CI"] != nil ? .ci : .dogfood,
            runId: ProcessInfo.processInfo.environment["GITHUB_RUN_ID"] ?? UUID().uuidString,
            testRef: .init(file: issue.sourceCodeContext.location?.fileURL.lastPathComponent ?? "?",
                           name: testCase.name)
        ))
    }
}
```

```kotlin
// JUnit — apply as a @ClassRule on every contract-bearing test class
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import com.crossdeck.Crossdeck
import com.crossdeck.ContractFailureInput
import com.crossdeck.ContractFailureRunContext
import com.crossdeck.ContractTestRef
import com.crossdeck.CrossdeckContracts

class ContractFailureWatcher(private val cd: Crossdeck) : TestWatcher() {
    override fun failed(e: Throwable, description: Description) {
        val contract = CrossdeckContracts.findByTestName(description.methodName) ?: return
        cd.reportContractFailure(ContractFailureInput(
            contractId = contract.id,
            failureReason = e.message ?: e::class.qualifiedName ?: "unknown",
            runContext = if (System.getenv("CI") != null)
                ContractFailureRunContext.CI
            else
                ContractFailureRunContext.DOGFOOD,
            runId = System.getenv("GITHUB_RUN_ID") ?: java.util.UUID.randomUUID().toString(),
            testRef = ContractTestRef(file = description.className, name = description.methodName),
        ))
    }
}
```

## Roadmap

One contract carries `status: "proposed"` at the time of writing. It's the forward-looking lock on outbound webhook delivery ([outbound-delivery-roadmap](#ctr-outbound-delivery-roadmap)) — the shape is fixed in the repository before the feature ships so that the signer, the worker, the scheduler, and the dashboard surface all land against the registered contract rather than drifting into existence.

New pillars and new contracts get added as the platform grows. The two that we expect to register in the next planning window:

- **Server-side entitlement API stability.** A contract that pins the shape of `GET /v1/server/customers/ /entitlements` — the field set, the cache semantics, the `Etag` behaviour — so backend integrations can depend on a versioned surface.

- **Cross-rail customer-ID stability.** A contract that pins the rule that one human's `cdcust_…` never changes once minted within a project, even across rail-link events. The identity journal already enforces this; the contract registers it explicitly.

## Related

- [Identity verification](https://cross-deck.com/docs/identity-verification/) — the architecture document that explains the identity journal, the resolver order, and the secret-key boundary that several contracts depend on.

- [Entitlements & gating](https://cross-deck.com/docs/entitlements/) — defining entitlements, mapping rail products to entitlement keys, and the contract `isEntitled()` answers.

- [Web SDK error codes](https://cross-deck.com/docs/web-sdk/error-codes/) — the canonical catalogue referenced by [sdk-error-codes-catalogue](#ctr-sdk-error-codes-catalogue).

- [Privacy Policy](https://cross-deck.com/legal/privacy/) — the legal framing of the independent-controller flow referenced by [contract-failed-payload-schema-lock](#ctr-contract-failed-payload-schema-lock).

- [Security Overview](https://cross-deck.com/legal/security/) — the operational discipline around incident response, encryption, retention.

- [SDK Data Collection Reference](https://cross-deck.com/legal/sdk-data/) — the exhaustive per-event field set every SDK captures, including the reliability-channel diagnostic payload.

- contracts/ on GitHub — the canonical JSON files plus the monorepo developer tutorial at contracts/README.md.

Last updated May 27, 2026. Every contract on this page is enforced or proposed at HEAD of the monorepo's `main` branch. The corresponding JSON is the canonical source; this document is a rendering of it for human reading.
