The Ticket Exchange, on the VTT Side — ADR-130
The Ticket Exchange, on the VTT Side — ADR-130
One ruling from Phase 3b’s identity workstream (2026-08-09, owner brief). The seam between the VTT and the Marketplace has been specified since 2026-08-05 —
Marketplace_Architecture.mdis authoritative for the VTT’s half and ADR-096/097 close its two open items — but nothing implemented it. The client sent a hardcoded'dev-ticket'and the silo read no credential of any kind:IViewerAuthority.TryResolvehas always taken aClaimsPrincipaldocumented as “the authenticated caller, as the server established them”, and the value passed to it wasnullon every call in the repository.It is not an audit round and mints no
R*-F-*number. It was found by an implementer at the seam, the same route ADR-124, ADR-125 and R29 came by.What made it worth a ruling rather than an implementation. The brief that commissioned it specified three things this record does not do, and each refusal is a constraint the corpus already owned that the brief could not have known was load-bearing. They are set out under Decision rather than in a footnote, because a reader comparing the brief to the code will otherwise read them as drift.
ADR-130 — Connection Identity Is a Redeemed Ticket, Established Once Per Connection, Carrying No Entitlement
Status: Accepted · Date: 2026-08-09 · Depends-on 029 (the SignalR-over-WebSockets transport whose handshake carries the ticket, and whose no-downgrade rule is what makes one redemption per connection true), Depends-on 054 (the Edge-Hold ladder and the distinct-wake-state requirement the two new connection phases answer to), Depends-on 078 (the erasure-by-construction property that forbids deriving a viewer id from the subject) · Amends 097 (clause 3’s contract test is restated as owed and still absent) · interacts with 085, 050, 123
Context
Marketplace_Architecture.md §2 states the seam exhaustively: exactly one integration surface
in each direction, and the VTT→Marketplace direction is a POST /api/auth/ticket/redeem
carrying “a 15-second single-use ticket; never an identity JWT”. §2.1 explains what that
buys — the identity JWT’s blast radius stays inside the context that mints it, so a VTT
compromise cannot mint Marketplace credentials — and §4.8 records the asymmetry that is the
payoff: a Marketplace outage “does not disconnect live VTT sessions. It prevents new
connections from authenticating, which is a degradation, not an outage.”
None of it was built. Three consequences were live in the repository:
The silo authenticated nobody. UnidentifiedViewerAuthority — the only IViewerAuthority
any host registered — admits every caller with ViewerRole.Unknown and the Public channel.
Its own remarks are careful to say refusing was considered and rejected because an
unconfigured host would look like a broken transport. That was the right call while nothing
established identity. It stopped being the right call the moment something could.
The client’s ticket was a constant in a component. App.tsx called
net.connect(DEV_ROOM, 'dev-ticket'). The value being fake was the visible problem; the
parameter’s position was the real one, and is the subject of clause 3 below.
ADR-097 clause 3’s second contract test does not exist, and this ruling does not create
it. Restated here so that implementing the ticket half is not mistaken for discharging the
entitlement half — nothing in this repository reads vtt_entitlement_v1.
Decision
1. The redemption result carries a subject and an entitlement hint, and cannot express a
role. The brief asked for “a strongly typed identity/claims object (e.g. ViewerId and
Role)”. Marketplace_Architecture.md §2.1 forbids the second field in as many words: “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.” The rule
is not about the client — it is about where a disclosure input comes from, and a field
populated by another bounded context is no more derived by this server than one populated by a
browser. TicketRedemption therefore has two payload members and no third; the guard is that
the type cannot express the wrong thing (P3), and the boundary assertion exists so that
adding a property does not silently start emitting a claim.
2. Authentication establishes who; it widens nothing about what may be seen.
RedeemedSubjectViewerAuthority differs from the permissive default by exactly one
return false. It resolves ViewerRole.Unknown and an empty owned-actor set — identical to
what the permissive authority granted. This ruling changes no viewer’s disclosure set. The
room membership model is an open product decision (ADR-085) and security-and-disclosure.md
§10 forbids inventing it in code; a ruling that authenticated callers and decided what they
may see would have decided the second question by accident, while looking like one feature.
3. The client acquires a ticket once per connection ATTEMPT, not once per connect(). The
brief placed acquisition in App.tsx, before net.connect(roomId, ticket). That is wrong for
a reason that is invisible in development and severe in production: a §4.5 ticket is
single-use with a ~15-second life, and ADR-054’s Edge-Hold ladder runs up to forty attempts
with backoff capped at thirty seconds and is re-entered on every dropped socket. A ticket
passed into connect is therefore already dead by the second attempt and already spent by the
first retry — so the first connection of a session succeeds and every recovery afterwards
fails, reporting a rejected credential for one that was valid when issued. Acquisition moved
into the ladder, immediately before the handshake that spends it.
Two properties fall out of that placement rather than being designed separately: the main
thread never holds a bearer credential, which is what §4.5 requires of this route — “Zero
frontend auth state. No token in JS, no localStorage” — and the fetch lands inside
src/network/, which check-network-boundary.mjs rule 2 requires anyway.
4. Redemption is skipped on the SignalR negotiate request, and that single condition is what
makes a single-use credential survive the transport. A SignalR connection is two HTTP
requests — a POST …/negotiate and then the connection request — and ASP.NET Core
authenticates each independently. With a JWT that is harmless; verifying a signature twice
gives the same answer. §4.5’s ticket is a database row consumed by one atomic guarded update,
so a handler that redeemed on both would consume it at negotiate and present an
already-consumed value at the upgrade. Every connection in production would fail while every
test passed, because any test host affordable enough to run uses an idempotent dev redeemer
that cannot notice being called twice.
The skip is keyed on the negotiate path, not on IsWebSocketRequest. The latter is more
precise and fails in the wrong direction: a host that (contrary to ADR-029) enabled long
polling would then authenticate nobody, silently. Keyed on the path, long polling redeems
once per poll — loud, and immediately wrong against a real Marketplace. Exactly-one-per-
connection therefore holds on WebSockets, which ADR-029 makes the only supported
transport, and that scope is stated rather than implied.
5. A refused ticket does not abort the connection. The brief asked that it should (“Drop the connection if redemption fails”), and the objection is measured against what a browser can observe. An HTTP status on a WebSocket upgrade is not exposed to page JavaScript — the socket reports a close with code 1006 and no reason. A drop would therefore reach the client as an unclassifiable transport error and be retried on the Edge-Hold ladder, up to forty times, against a credential that can never work: precisely the failure ADR-054 clause 5 names, where “the player watches ‘server is awakening’ forever while support diagnoses a capacity incident that is not happening.”
The connection is left inert instead. It carries no principal, so RoomHub.JoinRoom
refuses it with the HubException the client already classifies as unauthorised and treats
as terminal. The zero-trust property the brief was protecting is unchanged and is stronger
than an abort would make it: every hub method re-resolves authority through IViewerAuthority
on every call, so an unauthenticated connection can do nothing at any point, rather than being
stopped at one.
6. Identity mode ships no default on the silo and falls back on the client, and the
asymmetry is the decision. Descent:Identity:Mode has no default value: a silo that has not
chosen an identity source does not start. The client does the opposite — an unset
VITE_MARKETPLACE_ORIGIN selects the development ticket source. Failing closed on the server
and open on the client is safe only as a pair: the ticket the client invents is worth
exactly what the server will redeem it for, and a production silo in Marketplace mode refuses
'dev-ticket' like any other forgery. Moving either half breaks the argument for the other.
7. The ViewerId is minted per resolve and is NOT derived from the subject. This is the
change a reader will propose, because a stable viewer id looks like the obvious benefit of
finally knowing who is calling. Two independent rules forbid it. IntentIngress.TryResolveBound
records the first from the identity side: a resolver’s id is not identity, because “the
connection binding is the only thing that is per-session and unique by construction”, and a
subject-derived id would be shared by two connections of one person — “two players share one
lease, which is worse than the bug” it was written to fix. ADR-078 is the stronger reason: an
id that is a pure function of the Marketplace subject makes the identity linkage the hash
function, recomputable by anyone holding the subject and therefore not severable by
deleting a record — destroying exactly the erasure-by-construction property ADR-078 buys.
The durable Global-scoped subject-to-viewer mapping that would do this properly does not
exist in this repository, and is recorded below as owed rather than done.
8. Two connection phases are added, not one. unauthenticated (there is no session; not
a wait — the sibling of client-outdated, resolvable only by the player signing in on
another origin) and identity-unavailable (the Marketplace could not be reached; a wait, and
the ladder keeps running). Collapsing the second into server-waking would tell a player, and
then support, that the game silo is unwell when §4.8 says it is not — a Marketplace outage
leaves every live session untouched. Guardrail 6’s rule is never one spinner for two waits,
and these are two waits about two systems.
Alternatives Considered and Why Rejected
[Authorize]on the hub, the standard ASP.NET Core shape. Rejected on clause 4’s mechanism: endpoint authorization evaluates on negotiate and on the connection request, so it redeems twice. There is no ordering or scheme configuration that avoids this, because the two are separate HTTP requests and the framework has no per-connection authentication cache.skipNegotiation: true, so that there is only one request to authenticate. Genuinely fixes the double redemption and was the leading candidate for some time. Rejected because it destroys the failure classification: without negotiate there is no readable HTTP status anywhere in the handshake, so a rejected ticket becomes indistinguishable from a network fault and Guardrail 6’s distinct terminal state is unreachable. It also forecloses Azure SignalR should the deployment ever want it, for a problem clause 4 solves at no cost.- A short-lived cache of redemption results, keyed by ticket hash, so both requests may redeem. Rejected: it widens the replay window from “one redemption” to “one redemption plus the cache TTL on this silo”, trading a security property for a mechanism that clause 4 obtains for free. It would also have been the second durable copy of revocable state that ADR-097’s rejected push model was rejected for, at smaller scale.
- Verifying the identity JWT locally against a cached JWKS, removing the HTTP call from the connection path. The optimisation a future reader will propose, and it will not look like a security change — it will look like removing a round trip. Rejected because it is the whole of what §2.1 exists to prevent: it puts the issuing context’s key material inside this context’s blast radius, and the VTT would then hold everything needed to mint Marketplace credentials.
- Retrying a redemption on failure. Rejected as unsound rather than as expensive: the credential is single-use, so a retry after a response that may have committed presents a ticket this silo has already consumed. Retrying is only safe for idempotent requests and this one is defined not to be. The client retries instead, by acquiring a fresh ticket, which is the only retry that can work.
- Retrying client-side on
unauthorisedwith a newly acquired ticket. Tempting, because a ticket legitimately expires. Rejected: it converts the one signal that a credential was forged or replayed into an unbounded retry loop against the auth endpoint, whichOI-M-21already records as having no rate-limit policy — “an unlimited ticket-exchange endpoint is a free amplifier against the auth path.” - Distinguishing “rejected” from “the Marketplace is down” to the connecting client. The silo keeps the distinction internally and does not return it. Rejected as an availability oracle for another bounded context, and unnecessary: a client learns about a Marketplace outage from its own acquisition failing, which happens first. The residual is named below.
Consequences (including negative)
- A silo upgraded to this build refuses every connection until
Descent:Identityis configured. That is a deployment break, deliberately, and it is the fail-closed direction: the alternative is a silo that comes up and authenticates nobody, which looks healthy. - The negotiate endpoint is anonymous. It discloses a connection id, a connection token and
a transport list, and nothing about a room, a viewer or a campaign. The property is “the
negotiate endpoint is anonymous”, not “the hub is” — but it is a real reduction from what
[Authorize]would have given, and it is bought to make clause 4 work. - A Marketplace failing in the window between acquisition and redemption presents to that one client as a rejected ticket. The two refusals are distinguished in the silo’s logs and not to the caller, so this narrow race produces a terminal state where a wait would have been correct. The client must reconnect. Named rather than mitigated; the mitigation would be an oracle.
Q-093andQ-094are two more numbers on the connection path. Both ship defaults, both are registered, and neither is measured — nor will a benchmark settle them, since they are choices about how long to wait rather than estimates of a cost.- Long polling redeems once per poll. Harmless in development against an idempotent redeemer and immediately broken against a real Marketplace, which is the direction chosen in clause 4. ADR-029 already forbids the transport; this makes the prohibition self-enforcing on the authentication path rather than only stated.
- Nothing reads
vtt_entitlement_v1, and theentitlementHintis established and consumed by nobody. It is carried because it arrives in the redemption response and recovering it later would mean re-plumbing the connection path. A reader must not infer from its presence that the entitlement lookup exists. - A durable subject-to-viewer mapping is owed (clause 7). Until it exists, a returning player is a new viewer on every connection — which is what the code already did, so nothing regressed, but the opportunity to fix it is deliberately not taken here because taking it wrongly costs ADR-078’s erasure guarantee.
Rights-holders (ADR-079)
The Marketplace subject is retained in process memory for a connection’s lifetime, as a claim on the connection principal, and reaches no event, no snapshot and no T1 payload — ADR-078’s opaque-identifier rule is preserved by clause 7 rather than by exception. Who can claim on it besides the platform: the subject themselves, and honouring an erasure claim requires nothing of this system, because the value is not persisted here and the record that links it to a person is the Marketplace’s. That answer is “nobody, in this repository” and it is recorded rather than left blank, per ADR-079’s rule that a blank field is not an acceptable answer.
Enforcement
TicketAuthenticationHandlerTests.ANegotiateDoesNotConsumeTheTicketandAConnectionRequestRedeemsExactlyOnceassert clause 4 against a counting redeemer. The pair is required: the first alone passes against a handler that skips every request and authenticates nobody.TicketAuthenticationHandlerTests.NoRoleClaimIsEverEmittedasserts clause 1 at the boundary; the structural guard is thatTicketRedemptionhas no member to carry one.RedeemedSubjectViewerAuthorityTests.AnAuthenticatedCallerIsEntitledToNothingBeyondBeingIdentifiedasserts clause 2 — that this ruling widens no disclosure — andTheViewerIdIsNotDerivedFromTheSubjectasserts clause 7.ticket-acquisition.test.tsasserts clause 3 by requiring three retries to present three distinct tickets. A ladder that hoisted acquisition out of the loop passes every connection test and fails only this one.connection-presentation.test.tsasserts clause 8’s tones in both directions againstisWaiting, and that the Marketplace states do not borrow the silo’s vocabulary.MarketplaceIdentityOptionsValidatoris clause 6’s enforcement:ValidateOnStartrefuses an unset mode, so the failure is a deployment that did not come up.check-vault-boundary.mjs(ADR-123 clause 7) governs the new player-facing copy and rejected its first draft for naming an outward-facing surface on a control.- ADR-097 clause 3’s contract test against a
vtt_entitlement_v1fixture remains absent. Listed here so implementing the ticket half is not read as discharging it.
§11 Index Line
ADR-130: The VTT's half of the Ticket Exchange is built — a redeemed ticket establishes a connection-scoped principal and the VTT never receives or parses the Marketplace identity JWT (§2.1, §4.8). The redemption result carries a subject and an entitlement hint and CANNOT express a role, because a role the server did not derive defeats the disclosure filter exactly as a self-asserted JoinRoom parameter would; authentication therefore establishes who is calling and widens no viewer's disclosure set, since the membership model is an open product decision (ADR-085). Redemption is skipped on the SignalR negotiate request — a connection is two authenticated HTTP requests and the ticket is a row consumed by one atomic update, so redeeming on both would fail every production connection while every test against an idempotent dev redeemer passed. The client acquires a ticket once per connection ATTEMPT rather than per connect(), because a 15-second single-use credential cannot survive ADR-054's forty-attempt ladder or any later reconnect. A refused ticket does NOT abort the connection: a WebSocket upgrade's HTTP status is invisible to page JavaScript, so a drop would be retried as an unclassifiable transport error — the connection is left inert and refused at the first hub method, which the client already classifies as terminal. skipNegotiation was rejected for destroying that classification, a redemption cache for widening replay, and local JWKS verification for putting the issuer's keys in this context's blast radius. The ViewerId stays per-connection rather than derived from the subject, because a derived one makes the identity linkage the hash function and is not severable by deleting a record (ADR-078). Identity mode ships no default on the silo and falls back on the client, which is safe only as a pair. (Depends-on 029, 054, 078. Amends 097. Introduces Q-093, Q-094.)