Contracts — the structural guarantees Crossdeck enforces about itself
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
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.
- 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), orretired(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
CrossdeckContractsregistry. 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.
- 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/:
{
"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 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/<pillar>/<id>.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:
// 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
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")
}
// 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, in a dogfood run, or in a customer's own contract-verification harness — the SDK exposes a one-call helper to report the failure:
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; the wire shape is itself schema-locked by the contract-failed-payload-schema-lock 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.
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.
// 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:
[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 [email protected]. 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: trueProduction: false |
Whether the boot self-test runs on Crossdeck.init(...). Hot-path verifiers are unaffected. Opt-in for production by setting explicitly. |
logVerifierResults |
Development: trueProduction: 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:
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/) 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
trueregardless of build environment. - Force off — pins the flag
falseregardless 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.
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. Specifically:
-
Source.
The verifier framework + verifier implementations are in
sdks/web/src/_contract-verifiers.ts. Every verifier is a small, auditable function — callEntitlementCache.setUserKey("user_A"), plant data, callsetUserKey("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.tsmodule documented in Privacy Policy §6 — 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 samecrossdeck.contract_failedevent the existing test-harness path already produces, with the addition of averification_phasefield set tobootorhot_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 enforces exactly that.
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
_DiagnosticTelemetrymodule 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 (
/24for IPv4,/48for 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): 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, surfaced to customers' own privacy disclosures via the Customer Disclosure Template ("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
Contractshape, add newpillarorstatusenum 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
enforcedwebreact-nativeswiftandroid
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.
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
enforcedwebnodereact-nativeswiftandroidbackend
syncPurchases() on every SDK derives a deterministic Idempotency-Key from the request body (UUID-shaped SHA-256 of crossdeck:purchases/sync:<rail>:<jws|token>). 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
enforcedswift
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
enforcedswiftbackend
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:<id>; 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
enforcedandroid
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
enforcedwebnodereact-nativeswiftandroidbackend
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
enforcedswiftandroid
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
enforcedwebnode
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
enforcedwebreact-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
enforcednode
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
enforcedswift
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
enforcedswift
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
enforcedwebnodereact-nativeswiftandroid
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
enforcedswift
Every Crossdeck SDK merges event properties with the precedence device < super < caller (caller-supplied values win over registered super-properties, which win over auto-attached device info). Pre-v1.4.0 Swift had it INVERTED (super < device < caller — device clobbered super), so a register('plan', 'pro') super-property was silently overridden by auto-attached device fields whenever keys collided. Cross-SDK funnel queries on super-property keys returned different answers per platform.
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
enforcednode
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 <email> - node · track-pii-scrub.test.ts
redacts card-number-shaped values to <card> - 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
enforcedreact-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
enforcedwebnodereact-nativeswiftandroid
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
enforcednode
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
enforcednodebackend
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 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
proposedbackend
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
enforcedwebnodeswiftandroidreact-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 / SDK Data Collection Reference §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
mainruns three workflows:Testexecutes the per-SDK suites;Contract Auditmatches everytestRefname against the real test catalogue;Deploy Functionspublishes 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, crossdeck-node, crossdeck-react-native, crossdeck-swift, 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
// 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
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)
}
// 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.
// 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 },
});
});
// 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)
))
}
}
// 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) — 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/<id>/entitlements— the field set, the cache semantics, theEtagbehaviour — 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
- Prove it yourself — the adversarial companion to this ledger. For every contract that runs live, the precise invariant, the surface you verify it on, the cases to try to break it, and the boundary where the guarantee stops.
- 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 — defining entitlements, mapping rail products to entitlement keys, and the contract
isEntitled()answers. - Web SDK error codes — the canonical catalogue referenced by sdk-error-codes-catalogue.
- Privacy Policy — the legal framing of the independent-controller flow referenced by contract-failed-payload-schema-lock.
- Security Overview — the operational discipline around incident response, encryption, retention.
- SDK Data Collection Reference — 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.