Skip to content

Durable Identity and the Entitlement Read — ADR-131

Durable Identity and the Entitlement Read — ADR-131

One ruling from Phase 3c (2026-08-09, owner brief), closing the two gaps ADR-130 recorded as owed: the durable subject-to-player mapping ADR-078 requires, and the entitlement consumption plus contract test ADR-097 clause 3 requires.

It is not an audit round, but it does mint one cross-context finding — M-F-02 — which is disposed of in clause 5 below.

The brief that commissioned it asked for the linkage to be keyed on ViewerId. That is the one instruction this record declines, and clause 1 is the argument, because the reason is not a preference: in this codebase ViewerId is the per-connection transport identity, and making it durable breaks the snapshot transport in a way that presents as a rendering bug.


ADR-131 — The Player Is Durable, the Viewer Is Per-Connection, and Entitlement Is One Bounded Read

Status: Accepted · Date: 2026-08-09 · Depends-on 078 (erasure by construction — the reason the mapping is stored rather than computed), Depends-on 097 (the Global-scoped entitlement surface and its five clauses), Depends-on 050 (the lease identity that a shared viewer id would collide on) · Amends 130 (its two recorded gaps are closed; its entitlementHint now has a consumer) · interacts with 042, 082, 085

Context

ADR-130 built the ticket exchange and recorded two things as deliberately not done. First, that a ViewerId is minted per resolve and not derived from the subject, with the note that a durable subject-to-viewer mapping “is a Global-scoped record that does not exist in this repository”. Second, that nothing reads vtt_entitlement_v1 and the entitlementHint is “established at authentication and consumed by nobody”.

Both gaps were real and both are closed here. The first one, however, could not be closed the way it was asked for.

Decision

1. The durable identifier is a new type, PlayerId, and ViewerId stays per-connection.

The brief asked that the Marketplace subject map to a durable ViewerId. In this codebase that identifier is the connection’s identity, and three existing mechanisms key on it being unique per socket:

  • ViewerConnections maps a viewer to at most one connection, replacing on rebind.
  • RoomSnapshotDispatcher keys its per-room registry on ViewerId, and Deregister removes the single entry.
  • ADR-050 leases are held against it.

So a durable ViewerId means two browser tabs of one person share it, and the consequences are not subtle: the second tab displaces the first in the connection table, so the first silently stops receiving snapshots — and because deregistration removes the one dictionary entry, whichever tab closes first stops the survivor receiving anything at all. A durable-identity change would present as a broken renderer, in files nobody would think to open. IntentIngress.TryResolveBound already records the adjacent version of this: a shared identity means “two players share one lease, which is worse than the bug” it was written to fix.

What ADR-078 actually requires is that events carry an opaque identifier whose linkage to a person is deletable. It says nothing about the transport’s routing id, and there is no reason the two should be one value. PlayerId is therefore a separate Domain type: durable, opaque, per person, and the only identifier for a human that may enter an event, a snapshot or a T1 payload. ViewerId is unchanged. The Marketplace makes the same distinction in its own schema and calls its version subject_ref.

2. The mapping is stored, never computed — and that is the whole of the erasure guarantee.

A PlayerId derived from the subject by any pure transformation — a hash, an HMAC, a UUIDv5 — would make the linkage the algorithm. Anyone holding the subject could recompute the player id, so deleting the row would sever nothing and the retained history would stay re-identifiable by exactly the party the erasure was performed against. The value is minted randomly and persisted, and the storage is the point rather than an implementation detail. The test for this is written as two independent stores disagreeing about the same subject, because a test asserting “not equal to this hash” would only rule out the derivation somebody thought to write down.

3. “Outside the event store” is satisfied in the sense that carries the guarantee, and not in the sense of a second database. ADR-078 requires the linkage to be held outside the event store so erasure does not violate the never-delete invariant. What that needs is a record that is deletable, is not an event, and is not in a stream. The table is all three, in its own schema (descent_identity), reached by Dapper rather than through the Marten session that appends events. A separate datastore would buy a separation the schema boundary already provides, at the cost of a second deployment, a second backup story and a distributed consistency problem. Stated plainly because the two readings of that phrase differ and a reader is entitled to know which one is being claimed.

4. Entitlement is one replica read, with at most one primary read behind a hint.

The surface is IEntitlementSurface, taking an EntitlementContext on every call and offering no overload that does not — clause 2’s “entitlement is not an ambient value that a caller may read without saying on whose behalf”, made structural. A context cannot be built from nothing: it requires an account id, itself only reachable by parsing a subject a redemption established.

The read is EXISTS against vtt_entitlement_v1 on the replica. A miss is authoritative unless the connection carries §2.5’s after_grant_id, in which case exactly one primary read follows and increments vtt_entitlement_primary_fallback_total. There is no loop, no retry policy and no recursion on the path: §2.2 names the failure of getting this wrong, where “an unbounded retry would convert every entitlement check into a potential primary read under replica lag — the re-coupling the replica exists to prevent, arriving through the mitigation rather than the mechanism.”

5. M-F-02 — the grant hint’s value is not checkable against the contract surface, only its presence.

§8’s DDL declares auth_tickets.grant_hint uuid, referencing entitlements.id. But vtt_entitlement_v1 projects (account_id, resource_urn, granted_at)it does not expose id. So the sharper implementation the hint invites — check whether the hinted grant is visible on the replica, and skip the primary read when it is — cannot be written against the _v1 contract at all.

Disposition: not a contradiction, and not a change request against the Marketplace. §2.5’s own sentence only needs the hint to say “this principal was granted something recently enough that a replica miss may be stale”, and presence carries that. The finding is recorded because the id-ness of the value is visible in the schema and will invite exactly the implementation that cannot work, and because a future _v2 that exposed id would make the sharper bound available — at which point this clause is the record of why it was not taken first.

6. Identity mode decides the linkage, and Marketplace mode will not start without durable storage. Descent:Identity:LinkageConnectionString is required when the mode is Marketplace, and unused in Dev — which has no PostgreSQL at all. An in-memory linkage in production would re-mint every player id on restart, so everything a person authored before it becomes unattributable and erasure has no row to delete: silent, unrecoverable, and invisible until someone replayed a stream.

Alternatives Considered and Why Rejected

  • A durable ViewerId, as briefed. Rejected on clause 1’s mechanism. Its cost is not theoretical and is asserted by two tests driving the real transport classes.
  • Making ViewerConnections and the dispatcher multi-connection per viewer, so a durable ViewerId would work. Rejected as a much larger change with disclosure consequences — the snapshot fan-out becomes one-to-many per viewer, and ADR-032’s targeted per-silo forwarding would need re-arguing — bought to avoid introducing one type.
  • Deriving the player id from the subject with a server-side pepper. Attractive because it needs no table. Rejected: it severs everyone’s linkage at once or nobody’s, which is not erasure, and a compromised pepper is a permanent re-identification key for all retained history.
  • A Marten document for the linkage. Rejected: it would put the linkage inside the store that owns the event stream — the reading of ADR-078 that clause 3 is explicitly trying not to rely on — and make “delete the linkage” an operation on the same session type that appends events.
  • Caching entitlement answers per room activation. Rejected by §3’s prohibition table and by ADR-097’s own rejected push model: revocation must have a real deletion path, and a durable cache is a revocation hole with a good reason.
  • Retrying the primary read. Rejected as the bound itself — see clause 4. A retry policy here would pass every behavioural test in the suite and make the primary read unbounded under exactly the condition that produces the most of them.
  • Failing startup when the entitlement grant is absent. Rejected: no code consumes an entitlement decision yet, so refusing to start over it would block a deployment on a capability nothing uses. The two connection strings must be set together or not at all, which is the part that is actually dangerous to get half-right.

Consequences (including negative)

  • A Marketplace-mode silo needs a new connection string or it will not start. Deliberate, and the same fail-closed direction ADR-130 took for the mode itself.
  • Dev mode’s linkage is per-process, so a restart re-mints player ids. Invisible today because nothing replays a stream authored by a previous run, and named here so it is not discovered as a bug.
  • The schema is auto-created, mirroring Marten’s existing posture rather than introducing the repository’s first migration pipeline underneath an identity table. dbup-postgresql is a package reference that nothing uses — a pre-existing gap this change deliberately does not close.
  • Nothing consumes an entitlement decision yet. The surface, the context, the hatch and the contract test all exist and are exercised; no authorisation decision calls OwnsAsync, because no room-level cartridge selection exists to hang one on. This is the same shape as the gap ADR-130 recorded about entitlementHint, and it is recorded rather than disguised.
  • Q-078 remains pending, so the fallback metric is observed and not alarmed — unchanged by this record, and restated because the metric now exists and could be mistaken for a control.
  • The contract test asserts the fixture, not the Marketplace. If the Marketplace changes the view and nobody updates the fixture, this repository stays green and breaks at runtime. That is the intended residual of clause 3’s “two independent assertions” — one repository’s green build must not be able to break the other’s runtime, which necessarily means neither can prove the other correct.

Rights-holders (ADR-079)

This record retains one new class of data: the subject-to-player linkage. Who can claim on it besides the platform: the subject themselves. Honouring an erasure claim requires deleting one row, which ISubjectPlayerLinkage.SeverAsync performs; everything the person authored remains and replays, authored by an identifier that maps to nobody. No other party can claim on it — the Marketplace holds its own copy of the subject and this table adds nothing to what that context already knows.

Enforcement

  • SubjectPlayerLinkageTests.ThePlayerIdIsMintedRatherThanDerivedFromTheSubject — clause 2, as a disagreement between two independent stores.
  • SubjectPlayerLinkageTests.SeveringMakesTheSameSubjectResolveToANewPlayer — the erasure path.
  • ViewerIdentityIsPerConnectionTests — clause 1’s defect, driven against RoomSnapshotDispatcher and ViewerConnections themselves.
  • Adr097EntitlementSurfaceTests — one naming site for the view, no path from the disclosure layer to the entitlement surface (clause 4), and no Marketplace subject in the domain assembly (ADR-078).
  • EntitlementContractTestsADR-097 clause 3, discharged. Exact column-set and type equality against the checked-in fixture, and the shipped query executed against it.
  • FreshReadEscapeHatchTests — clause 5’s bound, observed through vtt_entitlement_primary_fallback_total rather than by instrumenting the data source, so the test also proves §2.5’s named metric exists.
  • MarketplaceIdentityOptionsValidator — clause 6, at startup.

§11 Index Line

ADR-131: The durable identity ADR-078 requires is a NEW type, PlayerId, and ViewerId stays per-connection — because ViewerId is the transport's routing identity, so making it durable makes two tabs of one person share it, and ViewerConnections then leaves only the latest addressable while RoomSnapshotDispatcher's single keyed entry means the first tab to close silences the survivor: a durable-identity change presenting as a renderer bug. The subject-to-player mapping is STORED and never computed, because any pure derivation makes the linkage the algorithm — recomputable by anyone holding the subject, so deleting the row would sever nothing and leave retained history re-identifiable by the very party the erasure was performed against. "Outside the event store" is claimed in the sense that carries the guarantee (deletable, not an event, not in a stream, own schema, not the Marten session) and explicitly not as a second database. Entitlement is one EXISTS against vtt_entitlement_v1 on the replica through a typed surface that cannot be called without an EntitlementContext, with a replica miss authoritative unless §2.5's grant hint is present, in which case exactly one primary read follows and increments vtt_entitlement_primary_fallback_total; there is no loop, no retry and no cache. Finding M-F-02: the hint's VALUE cannot be checked against the contract, because vtt_entitlement_v1 projects (account_id, resource_urn, granted_at) and does not expose entitlements.id — so only its presence is load-bearing, and the sharper "skip the primary read when the hinted grant is already visible" is unwritable against _v1. Marketplace mode will not start without durable linkage storage. (Depends-on 078, 097, 050. Amends 130. Discharges ADR-097 clause 3.)