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 ownADR-M,Q-M,OI-MandRC-Mnamespaces 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-01andOI-M-02and 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.
| Direction | Mechanism | What crosses |
|---|---|---|
| Marketplace → VTT | vtt_entitlement_v1, a view on a Neon read replica | Three columns; revoked rows already filtered |
| VTT → Marketplace | POST /api/auth/ticket redemption | A 15-second single-use ticket; never an identity JWT |
| Either → either | nothing 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:
- One direction. The VTT’s database role holds
SELECTon one view and nothing else. It has no write grant anywhere in the Marketplace schema. - A view, not a table. The VTT never sees
entitlements. The view is the contract; the table beneath it may be reshaped freely. - Expand-only migration. A new version is a new view served alongside the old for a
published window. Altering
_v1’s shape fails CI. - 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.
| Prohibition | Why |
|---|---|
| No write to any Marketplace table, ever | The 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 codebase | PCI 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 frontend | Same 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 path | A 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 lifetime | Revocation 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
- The game client route is cross-origin isolated (
COOP: same-origin,COEP: require-corp), and self-hosts 100% of its subresources. This is retained. - 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,WebCodecspipelines — 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”. - Billing, authentication and marketing routes are not isolated and live on separate paths or subdomains, as §6.1 already specifies.
- 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:
SharedArrayBufferis 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
SharedArrayBufferdependency; clause 2 makes the answer “then it needs an ADR” rather than a judgement call. OI-M-01is 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
vtt_entitlement_v1is admitted toGlobalscope, read-only, as the single entitlement surface. No second surface may be added without amending this record.- 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. - A
Globalentry 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. - 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”.
- 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
DELETEthe 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
SELECTonentitlementsdirectly. 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)
Globalscope 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;
NetArchTestinDescent.ArchitectureTestsfails 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’sRC-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-078bounds 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-02is 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/NOTIFYprecisely 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
| Id | Item | Blocked on |
|---|---|---|
Q-078 | Fresh-read primary-fallback rate above which the replica is unhealthy | Measurement against a live replica; absent, the metric is observed and not alarmed |
OI-V-01 | The 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 |