Skip to content

Marketplace Integration — the VTT Side of the Contract, and Two ADRs It Requires

Marketplace Integration — the VTT Side of the Contract, and Two ADRs It Requires

Date: 2026-08-05 · Written in English per the 2026-08-01 convention change.

Status: authoritative for the seam only. The Marketplace’s own architecture is specified in Descent Marketplace System Architecture Whitepaper.md, which carries its own ADR-M, Q-M, OI-M and RC-M namespaces and is authoritative for everything inside its boundary. This file is authoritative for what the VTT does, and exists because the Marketplace document correctly identified two obligations that fall on this corpus and could not be discharged from inside its own.

Provenance. The Marketplace specification names them OI-M-01 and OI-M-02 and says of the second: “Recording it here rather than assuming it is exactly the failure mode the VTT corpus names as ‘ADR A asserts an outcome; the mechanism lives under ADR B; nobody owns the join’.” That diagnosis was correct, the join was unowned, and this file is the owner.


1. Why this is a separate bounded context, and what that costs

The Marketplace is a full-stack bounded context with its own deployment, origin, repository, release cadence and datastore compute. It is not a module of the VTT backend and not a route of the VTT frontend.

The argument is asymmetry, and it is worth stating in the form that survives scrutiny. The VTT runs a fixed 20 Hz tick whose occupancy budget is ≤ 28 ms per 50 ms window (§5.2, Table A) and whose degradation is simultaneously visible to every player in every live room. The Marketplace serves bursty, cache-friendly, latency-tolerant HTTP. Co-hosted, a catalogue crawl or a launch-day spike competes for CPU with a loop that cannot yield. The Marketplace failing costs revenue for minutes; the tick slipping costs every live session at once. Isolation is bought by the cheap side to protect the expensive one.

Three consequences are accepted rather than mitigated:

  • Two deployments, two CI pipelines, two on-call surfaces.
  • A cross-origin authentication protocol that would otherwise be a cookie read.
  • A shared database contract that must be versioned like a public API.

The COEP argument is explicitly not load-bearing, and this corpus is the reason. The Marketplace’s finding M-F-01 observes that the common justification — “the VTT requires cross-origin isolation for SharedArrayBuffer, and Stripe’s iframes cannot be embedded under require-corp” — is not established by this specification. ADR-052 retired the shared linear-memory arena and specifies the Descent.Geometry WASM build with private memory and no +atomics; §6.1 records that the blast radius of losing isolation is “one degradation, not four” and that no capability and no correctness property depends on it. A justification that this corpus contradicts must not be carried, and ADR-096 below records the posture so that neither document has to infer the other’s answer.


2. The seam, stated exhaustively

There is exactly one integration surface, and it is read-only in this direction.

DirectionMechanismWhat crosses
Marketplace → VTTvtt_entitlement_v1, a view on a Neon read replicaThree columns; revoked rows already filtered
VTT → MarketplacePOST /api/auth/ticket redemptionA 15-second single-use ticket; never an identity JWT
Either → eithernothing else

Four properties are mechanically enforced on the Marketplace side (RC-M-08, RC-M-09) and are restated here because a contract asserted in one repository is a contract nobody owns:

  1. One direction. The VTT’s database role holds SELECT on one view and nothing else. It has no write grant anywhere in the Marketplace schema.
  2. A view, not a table. The VTT never sees entitlements. The view is the contract; the table beneath it may be reshaped freely.
  3. Expand-only migration. A new version is a new view served alongside the old for a published window. Altering _v1’s shape fails CI.
  4. A contract test in both repositories. This corpus owes the second one — see §5.

2.1 The VTT never parses an identity token

The VTT redeems a ticket and establishes its own connection-scoped principal from the redemption response. It never receives and never parses the Marketplace’s identity JWT.

This is not ceremony. It means a VTT compromise cannot mint Marketplace credentials — the blast radius of the JWT stays inside the context that issues it. It also means a Marketplace outage does not disconnect live sessions; it prevents new connections from authenticating, which is a degradation rather than an outage. That asymmetry is the payoff for the indirection and should be preserved by anyone tempted to “simplify” the handshake into a shared cookie.

This composes with, and does not replace, IViewerAuthority. §8.2’s rule stands unchanged: the client never asserts its own entitlement, and JoinRoom takes a room id and nothing else. Ticket redemption is how the server learns who is calling; it is not a channel by which a caller declares what it may see. A redemption response that carried a role or an ownership set the server did not derive would defeat the disclosure system exactly as a self-asserted JoinRoom parameter would.

2.2 Replica lag is a correctness surface, not a performance note

Reading a replica means a just-purchased Cartridge is invisible to the silo for the replication window (Q-M-010, target p99 < 500 ms). For a buyer who purchases mid-session and immediately tries to load the ruleset, that window is a support ticket.

The resolution is a bounded fresh-read escape hatch: the entitlement lookup accepts an after_grant_id hint carried in the ticket; when present and not yet visible on the replica, the silo retries once against the primary. This is the only primary read the VTT performs. Its rate is a metric whose growth means the replica is unhealthy, not that the hatch is working.

The bound is the point. An unbounded retry would convert every entitlement check into a potential primary read under replica lag — which is the re-coupling the replica exists to prevent, arriving through the mitigation rather than the mechanism.


3. What the VTT may not do — the prohibitions, and why each one is here

Each row is a thing that would look reasonable in review.

ProhibitionWhy
No write to any Marketplace table, everThe SELECT-only grant is the enforcement; a code path that “just needs to mark a download” would require widening it, and a widened grant is permanent
No Stripe SDK, key, or webhook handler in this codebasePCI scope containment (§8.6 of the Marketplace spec) holds the platform at SAQ-A; that boundary is which origin loads Stripe.js, and this origin loads a WASM sandbox and third-party mods
No entitlement decision in the frontendSame rule as §9.2’s authority boundary: an entitlement the client evaluates is an entitlement the client can be patched to grant
No synchronous call to the Marketplace on the tick pathA 20 Hz loop must not acquire a dependency on an HTTP service with a different availability target; entitlement reads are replica SELECTs, not API calls
No caching of entitlement state beyond a room’s lifetimeRevocation must have a real deletion path (§7.2); a durable cache is a revocation hole with a good reason
No Marketplace-originated code in the VTT process§6.4’s tier table governs; a Marketplace bundle is tier P3 — data only, and its arrival changes nothing about that

4. ADR-096 — Cross-Origin Isolation Is a Recorded Posture, Not an Inferred One

Status: Accepted · Date: 2026-08-05 · Amends: 052 · interacts with 034, and with ADR-M-001 in the Marketplace corpus

Context

Two documents implied different answers to whether the VTT ships cross-origin isolated. §6.1 describes route partitioning, a CI check failing the build on any external host in the game route’s bundle graph, and the conclusion that after ADR-052 no capability and no correctness property depends on isolation — its only remaining consumers are two Render-Worker-to-Main-Thread channels whose absence relaxes the frame-skew budget by one frame. The Marketplace corpus, reasoning from the same source, recorded finding M-F-01 that the isolation premise “is not established by the VTT specification” and raised OI-M-01 asking this corpus to say so explicitly.

Both readings are defensible against the text, which is the defect. A posture that must be reconstructed from three sections is a posture that will be reconstructed differently by the next reader.

Decision

  1. The game client route is cross-origin isolated (COOP: same-origin, COEP: require-corp), and self-hosts 100% of its subresources. This is retained.
  2. Nothing may be built that requires it. Isolation is an optimisation with exactly two named consumers (§9.1.2, §9.1.3). Any future subsystem proposing to depend on SharedArrayBuffer — a multithreaded Havok build, audio-worklet ring buffers, WebCodecs pipelines — requires its own ADR that states what happens when isolation is absent, because the answer today is “one frame of skew” and that must not silently become “the feature does not work”.
  3. Billing, authentication and marketing routes are not isolated and live on separate paths or subdomains, as §6.1 already specifies.
  4. No external context may reason about this posture. The Marketplace is isolated for blast radius, PCI scope and release cadence — reasons that hold whatever this clause says. Its architecture must remain correct if clause 1 is reversed tomorrow.

Alternatives Considered and Why Rejected

  • Drop isolation entirely, since nothing depends on it. Rejected: SharedArrayBuffer is still worth having, the CI bundle-graph check is cheap, and re-acquiring isolation after a third-party tag has been added to the game route is a far larger piece of work than never losing it. §6.1 already makes this argument and it is not weakened by being restated.
  • Leave it inferred and let each reader reconstruct it. Rejected: this is the status quo, and it produced two documents implying different answers within one week.
  • Record it in the Marketplace corpus. Rejected: it is a VTT property. A context that records another context’s posture creates precisely the unowned join this file exists to close.

Consequences (including negative)

  • A future subsystem wanting shared memory pays an ADR to get it. That is friction, and it is the intended kind: the previous cost of that decision was zero and the decision was therefore never visibly made.
  • Clause 4 constrains the Marketplace’s reasoning, not its code. It cannot be enforced by a test in either repository and is a review obligation. Stated because an unenforceable clause presented as enforced is worse than an acknowledged one.
  • The route partitioning cost in §6.1 — no third-party origin in the game bundle, ever — is now attached to a decision rather than to an inference, which makes it harder to remove for a plausible-sounding reason.

Enforcement

  • The existing §6.1 CI check (build fails on any external host in the game route’s bundle graph or CSP) is the mechanism for clause 1 and is unchanged.
  • A release-checklist item asks whether the release added a SharedArrayBuffer dependency; clause 2 makes the answer “then it needs an ADR” rather than a judgement call.
  • OI-M-01 is closed by this record.

§11 Index Line

ADR-096: Cross-origin isolation is retained for the game route and recorded as a posture rather than inferred from three sections, because two documents in the corpus implied different answers within a week; nothing may be built that depends on it without its own ADR stating the isolation-absent behaviour, since after ADR-052 the only consumers are two main-thread channels and the cost of absence is one frame of skew; billing and marketing routes stay non-isolated; and no external bounded context may reason about this posture, because the Marketplace's isolation must remain correct if clause 1 is reversed. (Amends ADR-052. Closes OI-M-01.)


5. ADR-097 — Entitlement Is a Global-Scoped Read Surface Owned by Another Context

Status: Accepted · Date: 2026-08-05 · Amends: 082 · Depends-on: 042 (typed scope surfaces) · interacts with 086, and with ADR-M-003 / §2.5 in the Marketplace corpus

Context

ADR-082 records that all customer data shares one store and one schema, that Row-Level Security is rejected because ADR-043’s projection worker legitimately reads across accounts, and that isolation is carried by ADR-042’s typed scope surfaces plus an entitlement context without which a Licence-scoped surface cannot be constructed. It also records that adding to Global scope is a review-gated act.

vtt_entitlement_v1 is platform-level data by that taxonomy. It is therefore a Global addition — and one whose writer is outside this system entirely, which the taxonomy did not previously contemplate. The Marketplace corpus raised this as OI-M-02 and correctly declined to decide it.

Decision

  1. vtt_entitlement_v1 is admitted to Global scope, read-only, as the single entitlement surface. No second surface may be added without amending this record.
  2. The scope surface is typed, and constructing it requires the entitlement context that ADR-082 already mandates for Licence-scoped reads. Entitlement is not an ambient value that a caller may read without saying on whose behalf.
  3. A Global entry with an external writer carries an additional obligation: the shape must be asserted by a test in this repository against a fixture, independently of the producing repository’s own assertion. Two independent assertions, because one repository’s green build must not be able to break the other’s runtime.
  4. Entitlement is authorisation input, never disclosure authority. ADR-086 bounds a plugin’s interest set by the disclosure set of the principal who admitted it, evaluated against §8.2’s Visibility Channels. Owning a Cartridge decides whether a room may load it; it decides nothing about what any viewer may see. These are separate predicates and must not be merged into one lookup because both happen to be called “permission”.
  5. The fresh-read escape hatch is bounded to one primary attempt per lookup, as §2.2 specifies, and its rate is a monitored metric rather than an accepted cost.

Alternatives Considered and Why Rejected

  • Have the Marketplace push entitlements into the VTT’s own store via an event. This is the decoupled-events shape that a reviewer will reach for, and it is genuinely attractive: no shared database, no cross-context grant. Rejected because it converts a consistency problem into a revocation problem — the VTT would then hold a durable replica of entitlement state that must be invalidated on refund, chargeback, licence lapse and takedown, and §7.2 requires those to have a real deletion path. A read-only view of the owner’s data has exactly one copy, and revocation is a DELETE the owner already performs. The push model trades a 500 ms lag window for an unbounded staleness window and four new failure modes.
  • Call a Marketplace HTTP API per check. Rejected: it places an HTTP service with a different availability target on the authorisation path of a 20 Hz loop.
  • Cache entitlements per room activation. Rejected for the same reason as the push model, at smaller scale; §3’s prohibition table forbids it.
  • Grant the VTT SELECT on entitlements directly. Rejected: the table is the Marketplace’s internal shape and would become frozen by an external consumer the moment the grant existed. The view exists to be reshaped.

Consequences (including negative)

  • Global scope now contains a surface this corpus does not own. That is a genuine weakening of ADR-082’s “one store, one schema” simplicity, and it is accepted because the alternative is a durable second copy of revocable state. The weakening is recorded rather than absorbed.
  • A Marketplace schema migration can break the VTT at runtime if both contract tests are wrong in the same way. Two assertions reduce the probability; they do not eliminate it. The residual is named.
  • Entitlement lookups are replica reads and therefore inherit replica availability. A replica outage degrades new room loads for paid content; it does not affect live sessions or free content. This is the intended failure shape and should be asserted by a test rather than assumed.
  • Clause 4 will be violated eventually by someone reasonably assuming that “does this user own it” and “may this user see it” are one question. The typed surfaces make the wrong one unavailable at the call site; the ADR explains why the type exists.

Enforcement

  • The entitlement scope surface is a typed construct requiring an entitlement context; NetArchTest in Descent.ArchitectureTests fails the build if any type constructs it without one, matching ADR-082’s existing pattern.
  • A contract test in this repository asserts vtt_entitlement_v1’s projected shape against a fixture (clause 3), independent of the Marketplace’s RC-M-08.
  • An architecture test asserts no type outside the entitlement surface holds a database grant against any Marketplace object.
  • A test asserts the primary-fallback path attempts at most one read (clause 5).
  • Q-078 bounds the fresh-read fallback rate at which the replica is declared unhealthy; it is pending and its absence means the metric is observed but not alarmed.
  • OI-M-02 is closed by this record.

§11 Index Line

ADR-097: vtt_entitlement_v1 is admitted to Global scope as a single read-only entitlement surface whose writer is another bounded context — a case ADR-082's taxonomy did not contemplate — accessed through a typed surface that cannot be constructed without an entitlement context; a Marketplace-pushed event replica was rejected because it converts a 500 ms consistency window into an unbounded revocation window that §7.2 requires a deletion path for; entitlement is authorisation input and never disclosure authority, since owning a Cartridge decides whether a room may load it and decides nothing about what any viewer may see; and a Global entry with an external writer obliges an independent contract test in this repository. (Amends ADR-082. Closes OI-M-02.)


6. What was rejected from the extended designs

Recorded so it is not re-proposed.

  • Marketplace as an Orleans grain family / VTT backend module. Rejected: a bursty HTTP workload sharing an activation scheduler with a 20 Hz tick, against §5.2’s 28 ms occupancy budget. This is the whole of §1.
  • Marketplace UI inside the VTT frontend route. Rejected on two independent grounds: PCI scope (§8.6 of the Marketplace spec) and the game route’s no-external-origin CI check (§6.1). Either alone is sufficient.
  • Shared SignalR hub between Marketplace and VTT. Rejected: the Marketplace’s own §6.1 selects SSE over LISTEN/NOTIFY precisely because SignalR’s backplane is the wrong shape for latency-tolerant notification fan-out, and §8.3’s no-fan-out rule would have to be re-litigated for a second consumer with different semantics.
  • A single shared identity cookie read directly by the VTT. Rejected: it would place the identity JWT inside the VTT’s blast radius, which §2.1 exists to prevent.

7. Open items this file creates

IdItemBlocked on
Q-078Fresh-read primary-fallback rate above which the replica is unhealthyMeasurement against a live replica; absent, the metric is observed and not alarmed
OI-V-01The failure-shape test for clause 3 of ADR-097 (replica outage degrades new paid-content loads only)Nothing — it is buildable now, and is listed so it is not assumed to exist