Descent VTT — Complete Records for ADR 001–031 (Archaeological Reconstruction)
Descent VTT — Complete Records for ADR 001–031 (Archaeological Reconstruction)
Date of reconstruction: 2026-08-01 Basis: the body text of
Descent VTT Enterprise System Architecture Whitepaper.md§2–§14, together with the references to the first thirty-one made in the ADR-032…071 records produced by the R1/R2 review rounds. Status of this document: a completion document. Records from ADR-032 onward are inArchitecture_Decision_Rulings_R1.md,W1–W6, andOpen_Items_S1_and_BENCH04.md; what this file supplies is 001–031, which had only index titles and never had complete records.Writing convention (as originally recorded): following the existing record files — ADR field names, identifiers, technical terms and API/library names remain in English; the body of the argument was written in Traditional Chinese. This document has since been translated in full; the convention is kept here as a record of how it was authored.
The Nature of This Document: This Is Archaeology, Not a Record
One thing must be made clear first, or every record that follows will be misread. The records from ADR-032 onward were written at the time: when the decision happened, Context was a real problem statement and Alternatives Considered were options genuinely considered and then rejected. 001–031 are not that. In the whitepaper they had only a single title line, no record was left when the decision was made, and this file infers the reasoning backwards from the outcome.
That difference has three practical consequences, written out one by one:
Contextis reliable. The whitepaper body describes the problem each decision solves quite completely, so the risk of inferring it backwards is low.Consequencesare broadly reliable, but cover only the consequences already discovered — many of which were dug up by the R1/R2 reviews, meaning they were not known when the decision was made. This is what an honest reconstruction should look like, not a defect.Alternatives Considered and Why Rejectedis the least reliable column. Wherever the whitepaper body states in as many words “we rejected X because Y”, the reconstruction is credible; wherever it does not, this file always marks ⚠️ Not recoverable archaeologically, and does not fabricate. An invented “alternative once considered” is worse than a blank: it would make future readers believe a path has already been evaluated and stop evaluating it.
The Alternatives column of this file therefore carries three markers:
- (no marker) = the whitepaper body explicitly records that alternative and the reason for rejecting it.
- ⚠️ Not recoverable archaeologically = whether other options were considered at the time cannot be known. This column is blank; it does not mean “there were no alternatives”.
- § Recorded after the fact = that alternative was raised and rejected only during the R1/R2 review, later in time than the original decision, and is recorded here so future readers do not raise it again.
ADR-001 — Strict Length Truncation & AST Lockouts for DoS Mitigation
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: —
Context
Descent.RngKit builds an AST parser for dice expressions using Superpower combinators (§4.1), and dice expressions are strings entered directly by players — not trusted configuration files. The parser has two structural weaknesses against arbitrary input: extremely long strings make parse time and memory grow with input length; and nested expressions unbounded in depth or width (((((...)))) or 1d(1d(1d(...)))) explode the AST node count, while a recursive descent parser additionally risks stack exhaustion on deep nesting. Any player can type such a string into the chat box.
Decision
Two limits, at the parser entry point:
- Physical truncation of the input string: over-long strings are refused before entering the parser, not aborted halfway through parsing.
- AST node bound lockouts: both depth and width have ceilings.
Both thresholds MUST be host-provided runtime configuration and MUST NOT be constants in the open-source repo (§3.1 Submodule Security Pinning rule 1) — Descent.RngKit is a public MIT submodule, and writing the thresholds as source constants hands the calibration parameters to the attacker.
Alternatives Considered and Why Rejected
⚠️ Not recoverable archaeologically. Whether “abort parsing on a timeout only” or “defend with rate limiting only” was evaluated at the time is unrecorded.
§ Recorded after the fact (established during the R1/R2 review; not part of the original decision): “defend with a timeout only” does not stand on its own, for the same reason the §4.3 Interruptibility Contract gives for regex — without explicit interruption points, a CPU limit is enforceable only at statement boundaries, and one catastrophic backtrack escapes it. “Defend with rate limiting only” also fails: when a single request can exhaust resources, limiting request frequency defers rather than prevents.
Consequences (including negative)
- Positive: the attack surface is closed at the parser entry point, depending on no downstream budget mechanism.
- Negative: the ceilings are user-visible feature ceilings. A cartridge author writing a complex custom mechanic will hit the AST width ceiling, and if the error message says only “expression too complex”, the author has no way to know what to simplify. The ceiling values MUST be documented.
- Negative: because the thresholds are runtime configuration rather than open-source constants, fuzzing MUST run against the production threshold set (§3.1 rule 3). A fuzz campaign run with open-source defaults covers a configuration no deployment actually uses, and the coverage it reports is coverage of the wrong system. This constraint is a direct cost of ADR-001.
Enforcement
- Beyond
Descent.Sandbox.Fuzzing/jint_vtt.dict, RngKit needs its own fuzz target covering degenerate expressions. - CI: thresholds MUST NOT appear as constants in the public repo (checkable by lint).
- §14.7: fuzzing MUST run against the production threshold set in private CI.
ADR-002 — Modular Monolith Architecture & Rejection of Microservices/K8s
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: clarified by 062
Context
The platform needs real-time interaction at ultra-low latency while also scaling to zero for cost control (§10.1). Microservices + Kubernetes is the industry default at this scale, and it conflicts with two core properties of this platform: a room’s state is single-authority, single-threaded (ADR-009), and splitting it across service boundaries turns in-process method calls into network calls and reintroduces the races ADR-009 eliminates; while the always-on control plane cost of K8s is directly opposed to the scale-to-zero FinOps goal (§10.2).
Decision
Adopt a Modular Monolith + Clean Architecture, explicitly and completely rejecting microservices and Kubernetes. Module boundaries are expressed by the .slnx solution file and project dependencies (§3), and enforced by Descent.ArchitectureTests.
This is horizontal scaling of a single deployable unit, not a microservice fleet (§4.4 Load-Aware Placement): every silo in the Orleans silo cluster runs the same image.
Alternatives Considered and Why Rejected
- Microservices + K8s: explicitly rejected (start of §2, §10.1). The reason given is “microservice overhead and operational costs”, plus §10.1’s “without the overhead of heavy Kubernetes (K8s) and JVM infrastructures (notably Elasticsearch)”.
- ⚠️ Not recoverable archaeologically: whether an intermediate form was evaluated (a handful of coarse-grained services — splitting out asset baking, say, while the rest stays monolithic) is unrecorded. Notably, the final architecture is in fact exactly that form — the Asset Baking Workers of §6.2,
Descent.Vtt.Workerof §7.1, and the Edge Fetch Service of §6.1 are all independent deployment units. That is to say, “Modular Monolith” describes the real-time path, not the whole system, and the original record never said so.
Consequences (including negative)
- Positive: the single-threaded nature of room state holds in-process, with no distributed locks needed.
- Positive: scale-to-zero is feasible (ACA), and the cost baseline of §10.2 holds.
- Negative: the “Modular Monolith” label does not entirely match the actual deployment form (see above). Several parts of the document argue from that label, while three always-on independent deployment units (Worker, Baking, Edge Fetch) each carry their own cost baseline and failure domain, and the ten-row table of §10.2 only listed them all in R2.
- Negative (made explicit by ADR-062): the combination of a monolith and multiple silos makes silo↔silo reachability a load-bearing dependency (ADR-032). If it is unsupported within an ACA Environment, the fallback is one silo per app (ADR-062) — which in deployment topology already looks a great deal like microservices, except every unit runs the same code.
Enforcement
Descent.ArchitectureTests: dependency boundaries.- §10.1.1: no K8s component appears in the deployment description.
ADR-003 — Single Authoritative World Model & Rendering Profiles
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: —
Context
The platform must serve high-end PC/VR, low-end laptops, and tablets/phones in the same room simultaneously. The most intuitive approach is for each device tier to hold a world representation suited to itself. In a multiplayer real-time TRPG that produces a fatal consequence: two players get different tactical information about the same wall.
Decision
The backend Domain layer maintains only the true physical and spatial world state (3D coordinates, region boundaries, light source properties), and never holds renderer-specific visual objects (Pixi Sprites, Babylon Meshes). Every renderer is a read-only projection of that single authoritative model. Device differences are expressed as Rendering Profiles (A/B/C), and a Profile determines how it is presented, not what the world contains.
Alternatives Considered and Why Rejected
- A separate world model per Profile: implicitly rejected by the decision itself. The reason §5.4.1 gives is verifiable: visibility is a security property, and two independent implementations mean two players get different tactical information about the same wall.
- ⚠️ Not recoverable archaeologically: whether “2D as authoritative with 3D as decoration” (the Foundry VTT route, see the §1 competitive matrix) was evaluated is unrecorded. From the positioning statement of §1 (“3D First”) it can be inferred that this was a product-level premise rather than an architecture-level evaluation.
Consequences (including negative)
- Positive: “one world, many viewpoints” holds, and is machine-checkable (T2 Prohibitions, §2.1.1).
- Negative (made explicit by ADR-061): this rule guarantees that all Profiles see the same world, but does not guarantee they receive the same narrative information. Profile C has no 3D scene to shade, so cross-Profile visual parity is structurally impossible. ADR-061 narrows the goal to semantic parity precisely because ADR-003’s wording had been read as a guarantee it never offered.
- Negative: “read-only projection” is a strong constraint: any frontend convenience optimization involving locally speculated state MUST go through the lease protocol of §5.1.1 and MUST NOT take shortcuts.
Enforcement
Descent.ArchitectureTests: the Domain layer MUST NOT reference any renderer type.- §9.5 Guardrail 7: the capability matrix for each Profile is a release gate.
ADR-004 — Hybrid Serialization Strategy (FlatBuffers 0-GC & MessagePack)
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: Amended-by 058
Context
The platform has three serialization needs of entirely different character: the 20Hz per-viewer world snapshot (high frequency, requiring zero steady-state allocation so GC spikes do not break the tick budget), cartridge-defined dynamic attribute sub-payloads (the schema is decided by third parties, so types cannot be pre-generated), and server-side cache payloads (purely internal, wanting only speed). No single format satisfies all three.
Decision
Three formats, divided by role:
- FlatBuffers — the client communication hot path (§8.1). The accurate claim is “one copy, zero object allocation per frame”: a received frame is copied once by the network worker into a
SharedArrayBuffer, after which field access reuses pre-allocated accessors, and steady-state rendering allocates nothing and triggers no GC. It is not zero-copy; that one copy is budgeted. - MessagePack — the cartridge’s dynamic sub-payloads (schema-less, third-party defined).
- MemoryPack — server-side caching.
Alternatives Considered and Why Rejected
- A single format (JSON only or MessagePack only): implicitly rejected by the three-way split in requirements; per-frame allocation on the high-frequency path would directly violate the tick budget of §5.2.
- ⚠️ Not recoverable archaeologically: whether Protobuf, Cap’n Proto, or Bond were evaluated is unrecorded.
Consequences (including negative)
- Positive: zero steady-state allocation on the hot path, so GC spikes do not enter the tick.
- Negative: three serialization toolchains and three sets of version evolution rules. The
.fbsschema of theDescent.Vtt.Protocolproject becomes a source of breaking changes across frontend and backend, and the minimum-version handshake of §9.5 Guardrail 6 / ADR-054 exists precisely for that. - Negative (corrected by ADR-058): the original design had room snapshots in the cache use MemoryPack while T1 used a different shape — two serialization shapes will drift. ADR-058 therefore specifies that room snapshots in the cache are a byte-identical copy of the T1 payload. The lesson here is general: “choose the fastest format for each purpose” produces a second source of truth whenever the same data has two homes.
- Negative (a normative requirement derived from §5.2 Segment B): the cartridge’s MessagePack sub-payload is serialized once per entity per tick, with the bytes shared across viewers. Otherwise the cost is O(viewers × entities) rather than O(entities), and 50 viewers pay 50× for identical bytes. This is an unstated consequence of ADR-004 that was only written down as normative in R2.
Enforcement
- Allocation budget assertions in the style of
Descent.RngKit.Benchmarks(hot-path loops). - §5.2 Segment B budget: per-viewer delta comparison and cartridge sub-payload sharing are both benchmark targets.
- ADR-058: CI asserts the cached snapshot is byte-identical to the T1 payload.
ADR-005 — PixiJS as a Profile C Adapter over Baked 2D Tiles, Not a Projected 3D Scene
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: Amended-by 066; interacts with 061
Context
Profile C targets ageing tablets and low-end devices. The initial intuitive prescription is “switch to an orthographic camera” — but an orthographic camera changes the framing, not the cost: it still submits the same meshes, materials, and texture working set. Ageing tablets are limited by fill rate, draw calls, and VRAM, and changing the camera saves none of them.
Decision
Profile C is defined by its asset tier, not by its projection matrix.
- Phase 1 (default): Babylon.js + an orthographic camera plus a mandatory reduced working set — baked top-down tiles replacing kitbash meshes, the lowest KTX2 mip only, no dynamic shadows, no post-processing, and a hard cap on simultaneous material count.
- Phase 2 (Legacy): a PixiJS read-only projection adapter rendering those baked 2D tiles — not a projected 3D scene, which PixiJS cannot draw.
- Baked 2D is a first-class pipeline artefact: every Kitbashing module and every asset bundle MUST declare a top-down 2D bake (sprite/tile + footprint), produced by the §6.2 asset pipeline. An asset bundle without a 2D bake is explicitly unavailable on Profile C, presented as a labelled footprint placeholder; the manifest advertises
profileCSupported, so a GM is warned at authoring time rather than a tablet player discovering an empty map.
Alternatives Considered and Why Rejected
- Switching to an orthographic camera only: explicitly rejected (§2.2 “The Real Constraint”). Framing is not cost.
- Projecting 2D from the 3D scene at runtime: explicitly rejected. PixiJS cannot draw a projected 3D scene, and runtime projection saves none of the cost items §2.2 lists.
- ⚠️ Not recoverable archaeologically: whether “do not support Profile C at all and direct users to a desktop” was evaluated is unrecorded.
Consequences (including negative)
- Positive: Profile C’s cost claim is achievable rather than aspirational.
- Negative: the asset pipeline gains an entire additional obligation. Every UGC author MUST produce a 2D bake, or their content is unavailable on an entire device tier. This is a real tax on the creator ecosystem, and it is a direct consequence of ADR-005 rather than an incidental condition.
- Negative (ADR-061): because there is no 3D scene to shade, cross-Profile visual parity is structurally impossible, and narrative effects MUST each declare per-profile equivalent expressions.
- Negative (ADR-066): ADR-005 speaks only of cost and says nothing at all about how people play. §2.2 labels Profile C “tablets/old devices”, while the roadmap, the FinOps model, and Guardrail 7 all quietly assume phones are covered too. ADR-066 therefore splits Profile C further by viewport role into C-Tactical and C-Companion. This is an instance of “a decision is complete only along the axis it claims” — isomorphic to the P4 gap in §6.4.
Enforcement
- Asset pipeline: an asset bundle without a 2D bake has
profileCSupportedfalse in its manifest and is presented as a placeholder on Profile C. - §9.5 Guardrail 7: every Profile C row’s degradation behaviour needs a passing test (ADR-064).
ADR-006 — Modular Asset Bundles & Presigned URL Pipeline
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: —
Context
Assets (3D models, 4K textures, audio) are large and community-uploaded. Routing uploads through the real-time SignalR host would have large file uploads contend with game traffic in the same process; and 3D geometry processing (collision mesh simplification, KTX2 transcoding) is high-CPU/high-RAM batch work that would “poison” the real-time host (§6.2).
Decision
- Modular asset bundles: described by a manifest (
bundleId,version,dependencies,assets,lods,collisionBounds), composable and versionable. - Presigned URL direct upload: the frontend uploads raw files directly to Cloudflare R2, the main server only publishes an
AssetBakeTaskto Service Bus, and Asset Baking Workers on ACA Jobs process it asynchronously. - Quotas are enforced at URL issuance, not at bake time: because the client uploads directly, the storage cost is incurred before any
RoomGrainsees the file, and aRoomGraintoken bucket cannot prevent it. Every presigned URL therefore carries acontent-length-rangeceiling, is single-use and short-lived, and is issued only after checking that account’s storage quota, hourly URL budget, and count of pending unbaked uploads. Unclaimed or excess objects are reclaimed by an R2 lifecycle rule.
Alternatives Considered and Why Rejected
- Uploads through the application layer: implicitly rejected by §6.2’s “compute separation” motivation — it is exactly the “poisoning the real-time SignalR host” being avoided.
- Checking quotas at bake time: explicitly rejected (§6.2). The cost is already incurred before baking.
- ⚠️ Not recoverable archaeologically: whether other object storage providers (besides R2) were evaluated is unrecorded; §10.1’s zero-egress argument implies R2 was a given that predates this decision.
Consequences (including negative)
- Positive: the real-time host never touches asset bytes at all.
- Positive: FinOps: ACA Jobs self-destruct on completion, terminating billing.
- Negative (revealed by ADR-039): “quotas hang off presigned URL issuance” has a bypass unforeseen at the time — the Edge Fetch Service of §6.1 does not travel this path at all. A player pastes an external URL, the platform fetches it on their behalf and stores it in R2, bypassing every quota here. ADR-039 therefore had to build an entire separate set of byte/rate ceilings for that service. This is an instance of “a quota enforced on one path is complete only to the extent of the list of paths reaching the same resource”.
Enforcement
- The presigned URL issuance endpoint: quota checks are a precondition of issuance.
- R2 lifecycle rule: reclamation of unclaimed objects.
- §10.3: the EDoS surface list MUST cover any user-triggerable storage write path.
ADR-007 — Simulation Authority Boundary & Intent Command Pattern
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: —
Context
In a 3D VTT the most natural implementation is to let the renderer move the token directly and then report the new coordinates to the server. That makes the client the first writer of state, and any anti-cheat claim collapses as a result — the server can only accept or reject a fact that has already happened.
Decision
Architectural boundary rule: renderers MUST NOT mutate Domain World State directly.
All frontend renderers (Babylon.js / PixiJS) are strictly read-only views. All player interactions (moving a token, opening a door, toggling a light, applying damage) MUST dispatch an Intent Command (MoveActorCommand, ToggleLightCommand) to the Application/Simulation layer for validation and execution. On successful validation, the RoomGrain produces a Domain Event written to Marten.
Alternatives Considered and Why Rejected
- The renderer mutating state directly: explicitly rejected; this is the entire content of this ADR.
- ⚠️ Not recoverable archaeologically: whether “client authority + server spot checks” (the approach of some competitors) was evaluated is unrecorded.
Consequences (including negative)
- Positive: anti-cheat holds by construction rather than depending on detection.
- Negative: input latency becomes an architecture-level problem. “Server First” brings 50–100ms of input latency (§9.6.4), and the whole lease protocol of §5.1.1, the client prediction of §9.6.4, and the interpolation buffer of §8.3 — three complex subsystems — exist to compensate for ADR-007. This is the single decision in this document that produces the most downstream complexity.
- Negative: “read-only view” and “client prediction” are in literal tension. §9.2 handles this by defining prediction explicitly as prediction of the server’s result, with visibility only ever presented and never predicted (ADR-034) — a distinction that is a security property, not a performance consideration.
Enforcement
Descent.ArchitectureTests: renderer types cannot reach a Domain mutation path.- §5.1.1: no lease means no prediction, falling back to server-driven movement.
ADR-008 — Event Sourcing & CQRS via Marten for Domain State
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: Amended-by 069
Context
Three of the platform’s product requirements point simultaneously at event sourcing: unlimited undo and time travel (§7.3), post-campaign replay archives (§7.3.2), and auditing when disputes arise (§4.1 dice fairness). A CRUD-based state store can provide none of them without building a parallel change log.
Decision
Domain state is persisted with Marten (an event store on PostgreSQL), with read models produced by CQRS projections. Three authority tiers (§2.1.1): T0 is the RoomGrain’s in-memory state (authoritative while active), T1 is the Marten event stream plus periodic snapshots (the durability authority; T0 MUST be exactly reconstructible from T1), and T2 is the JSONB projections (non-authoritative, eventually consistent by design).
Alternatives Considered and Why Rejected
- ⚠️ Not recoverable archaeologically. Whether CRUD + audit tables, or dedicated event stores such as EventStoreDB/Kafka, were evaluated is unrecorded. The reason for choosing Marten over a dedicated event store (one PostgreSQL instance carrying event streams, projections, pgvector, and PostGIS simultaneously, so no second operational stack is needed) is never written in the whitepaper, but can be reasonably inferred from the overall FinOps argument of §10.1 — marked here as inferred rather than verified.
Consequences (including negative)
- Positive: time travel, replay, and auditing all share one body of data, with no parallel log.
- Positive: “redeploy to fix the data” is never available, which instead forces §10.4 to write out an explicit recovery path for every failure class.
- Negative: T2’s non-authoritativeness MUST be enforced rather than declared. The four T2 Prohibitions of §2.1.1 plus
SourceEventSeqexist for that; and ADR-043 went further, finding that “detection is not protection” — during a rebuild, edits based on lagging T2 MUST be refused, not merely warned about. - Negative (revised by ADR-069): event sourcing does not by itself guarantee historical semantic stability. If events record the inputs awaiting adjudication, then every rules change silently rewrites what happened at the table. ADR-069 therefore specifies that events record adjudicated outcomes, with inputs attached only as evidence, and the rules engine never re-runs history. This is the most important qualification on ADR-008 at a ten-year scale, and the original decision never said it.
- Negative: high-frequency actions (60Hz dragging) cannot produce an event each time, so §7.1 must split state synchronization into ephemeral and authoritative — a direct cost of ADR-008 and the reason ADR-012 and ADR-016 exist.
Enforcement
Descent.ArchitectureTests: the four T2 Prohibitions.- §7.4: every projection is idempotent on
(stream, version), asserted per projection in CI. - §14.5: recorded session event streams are replayed in CI, asserting identical projection state (ADR-065).
ADR-009 — Virtual Actor Model (Microsoft Orleans) for Concurrency & Room Management
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: —
Context
Multiplayer shared room state on a stateless Web API inevitably produces serious races: two players issuing commands against the same token at once, or drawing from the same resource pool simultaneously. The traditional solutions are distributed locks (Redis) or in-process locks, both of which stake correctness on developers remembering to take the lock.
Decision
The Application layer abandons the traditional stateless Web API for the Microsoft Orleans Virtual Actor Model. Campaign rooms (RoomGrain) and complex entities are modelled as Grains; Orleans guarantees that only a single thread executes inside a Grain at any moment, entirely eliminating the need for manual lock, SemaphoreSlim, or Redis distributed locks.
Cluster membership is provided by ADO.NET Clustering (Neon PostgreSQL), giving a strongly consistent membership table against the split-brain risk of serverless scale churn.
Alternatives Considered and Why Rejected
- Stateless Web API + distributed locks: explicitly rejected (§4.4). Correctness would depend on human discipline.
- ⚠️ Not recoverable archaeologically: whether Akka.NET, Dapr Actors, or a self-built actor runtime were evaluated is unrecorded.
- ⚠️ Not recoverable archaeologically: the comparison behind choosing ADO.NET over Azure Table/Consul as the membership provider is unrecorded; §10.1 already has Neon PostgreSQL always on, from which avoiding a second operational stack can be inferred — marked as inferred.
Consequences (including negative)
- Positive: concurrency safety for room state holds by construction.
- Negative (the single largest constraint in this document): the Grain mailbox becomes a scarce resource. An activation processes one message at a time — including across
awaitpoints — which makes “awaiting anything inside the mailbox” occupy the entire room. The discipline ADR-018 drafted was insufficient, and ADR-033 had to extend it to the tick itself. The four prohibited work classes of §4.4, the sandbox pool of §4.3, the checkout saga of §7.3, and the hydration saga of §7.4 are all downstream consequences of ADR-009. - Negative: placement becomes an architectural problem.
[ActivationCountBasedPlacement]is unusable — activation count is not load — so §4.4 requires a custom weighted placement director. - Negative: the cold start chain includes “join the cluster and vote out stale membership rows”, a measurable cost item in §9.5 Guardrail 6 and not a free operation.
- Negative (revealed by ADR-032): Orleans placement is dynamic (idle deactivation, scale-in, rebalancing), so any room→silo mapping held by the edge layer is stale by construction. ADR-024’s sticky routing prescription died on exactly this point.
Enforcement
- §4.4 5ms rule: any Grain method on the request path with p99 over 5ms is a CI-tracked regression (
AdvanceTickAsyncis explicitly exempt and has its own SLO). Descent.IntegrationTests: Orleans Grain traffic.- §14.6: silo failover mid-tick is an injectable fault.
ADR-010 — Zero-DOM UGC Sandboxing (WASM/QuickJS Declarative UI vs. Iframes)
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: Amended-by 039, 040, 068
Context
Community character sheets and UGC workshop modules need custom UI and custom logic. The traditional means of isolating third-party UI is a cross-origin iframe, which provides a genuine security boundary but starts a complete framework runtime in each iframe (React/Vue) — an enormous RAM overhead for a VTT with a dozen character sheets open at once. Meanwhile, letting third parties deliver JavaScript to the main thread is DOM-based XSS/RCE.
Decision
A single normative rule: third parties deliver data, never code that reaches the main thread.
- JSON AST is the only delivery format: third-party rules authors write character sheets and panels as JSON AST UI Descriptors, validated against a published JSON Schema. The core SolidJS application (or a first-party Lit host) interprets that descriptor and instantiates first-party components. Creators never publish a JavaScript class, a custom element definition, a template literal, or any artefact executing in the main thread realm.
- Creator logic runs only in a QuickJS/WASM
PluginWorker(§9.5 Guardrail 5): a pure ES2020 environment with no DOM, nowindow, and nofetch. - Lit / Shadow DOM is a styling boundary, not a security boundary — labelled as such explicitly. Shadow DOM does not restrict
document,fetch,localStorage,window.top, or cookie access. Because §6.4 delivers no third-party code, this is moot by construction — and that is precisely the point.
Alternatives Considered and Why Rejected
- Cross-origin iframes: explicitly rejected (§9.5 Guardrail 5). Sufficiently secure, but the RAM overhead of a framework runtime per iframe is unacceptable.
- Relying on Shadow DOM for isolation: explicitly rejected, with the reason it does not work written out (it does not restrict
document/fetch/localStorage/window.top/cookies). - ⚠️ Not recoverable archaeologically: whether options beyond Figma-style realm isolation were evaluated (SES/Hardened JavaScript, or a Worker + restricted proxy, say) is unrecorded.
Consequences (including negative)
- Positive: DOM-based XSS/RCE is impossible by construction.
- Positive: immunity to future framework version migrations — a descriptor is data.
- Negative (revised by ADR-040): the original design of “a single QuickJS runtime carrying all plugins” has no isolation whatsoever in the time dimension. One plugin doing an accidental O(n²) scan over 500 tokens — QuickJS is an interpreter, so this is hundreds of milliseconds — starves every other plugin and the JSON-AST bridge, freezing the Keeper’s combat tracker with no clue as to the cause. ADR-040 therefore moved to one runtime per plugin and added a complete budget regime. The lesson: access control and resource control are different in kind, and a trust tier table listing only the former makes two tiers look equivalent when they are not.
- Negative (revealed by ADR-039): §6.4’s trust tier table claims to enumerate every extension mechanism and concludes “there is no fourth row” — what it missed is an entire category: the table classifies by “what executes”, and “a location named by the player, which we go and fetch” executes nothing. An enumeration is complete only along the axis it chose. ADR-039 adds P4.
- Negative (revised by ADR-068): JSON AST as the only delivery format means the platform cannot hand-write small-screen layouts on behalf of community cartridges, and a cartridge unusable on a phone is indistinguishable from a working one from the platform’s side. ADR-068 therefore requires descriptors to carry semantic role and priority annotations.
Enforcement
- JSON Schema validation performed at registration.
- §14.4: frontend dependency boundary assertions — plugin-facing SDK modules MUST NOT import the raw ECS or
GPUDevice. - §6.4: adding a trust tier row requires an ADR (ADR-045).
ADR-011 — Offline Synchronization via CQRS Command Queue & Server Reconciliation
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: Amended-by 051
Context
A PWA needs offline availability. Generic local-first database sync engines (PowerSync and the like) model this as “write locally, sync later” — which bypasses the authoritative backend’s validation logic, and in a server-authoritative architecture founded on ADR-007 amounts to surrendering the anti-cheat boundary.
Decision
While offline or on a high-latency link, the frontend uses Optimistic UI to update local SolidJS state immediately, while storing the action as an Intent (Command) in an IndexedDB queue. On reconnect the queue is flushed to the Orleans backend; commands refused by the authoritative RoomGrain are returned by the server as Reconciliation Events over FlatBuffers, and the frontend rolls the Optimistic UI back to the authoritative state.
Three additional rules (§9.5 Guardrail 4):
- Causality Rule: the queue is a causal chain, not a set. Refusing intent N atomically invalidates every dependent intent after it, presented as a single “these actions could not be applied” review. Rolling back only the refused one is unsafe — an attack launched from the vacated square after a refused move, or the spell paid for by a refused magic point deduction, would have the client resolve the consequences of a premise the server rejected.
- Timeline Rule: if the room timeline has advanced past, or forked away from, the base version a queued intent was built on (§7.3), the whole queue is refused as a batch with an explicit explanation. Rebasing intents onto an abandoned branch has no well-defined semantics and MUST never be attempted silently.
- Bounded Queue: the offline queue has count and age ceilings; on overflow the client refuses further optimistic actions rather than accumulating a divergence too large to reconcile meaningfully.
Alternatives Considered and Why Rejected
- Generic local-first sync engines (PowerSync and the like): explicitly rejected (§9.5 Guardrail 4). Bypasses authoritative validation; serious anti-cheat risk.
- Rolling back only the single refused intent: explicitly rejected, with two concrete counterexamples given (see above).
- Rebasing onto a forked timeline: explicitly rejected — no well-defined semantics.
Consequences (including negative)
- Positive: offline capability does not come at the cost of anti-cheat.
- Negative (revised by ADR-051): ADR-011 and §5.1.1 give opposite answers for the most common offline gesture. §5.1.1 requires holding a lease before predicting, and a lease cannot be acquired offline — so “drag a token offline” is a queueable intent under one rule and an impossible operation under the other. ADR-051 therefore changed eligibility to a per-Intent declared, default-ineligible whitelist, and demoted offline spatial actions to a local planning layer (presented for execution on reconnect rather than submitted automatically).
- Negative (ADR-051’s honest statement): “Offline-First” applies to authored content and character-sheet-layer state, not to world interaction. For a server-authoritative real-time VTT that is the correct boundary, but it is narrower than the term implies, and §13 states it accordingly.
- Negative (added by ADR-067): ADR-051 reasons about being offline, and a phone backgrounded by the user is not offline — its socket is very likely still open. ADR-067 therefore specifies that
visibilitychange → hiddenreleases all leases immediately.
Enforcement
- §9.5 Guardrail 4: the offline eligibility whitelist is fail-closed, with ineligible items disabled in the UI up front and the reason explained.
- §14.6: IndexedDB unavailability, and backgrounding, are both injectable capability vetoes.
ADR-012 — SFU Architecture (LiveKit) vs. P2P Mesh for High-Frequency Ephemeral Data
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: Amended-by 050
Context
3D cursors, rulers, and map pings are produced at 60Hz. Routing these through the .NET backend would bind server cost to client input rate and create GC spikes on the Orleans host. Delivering them over a P2P mesh would have each client upload N streams and download N — an O(N²) room total, infeasible for both CPU and bandwidth on mobile devices. Voice adds another requirement: spatial audio needs a separate track per player, which an MCU-style mixed output cannot recover.
Decision
Non-authoritative high-frequency data (cursors, rulers, pings) and voice are all routed through the LiveKit SFU (Selective Forwarding Unit) over WebRTC DataChannels, entirely bypassing the Orleans server. An SFU guarantees O(N) scaling — a client uploads exactly 1 stream and downloads N. The SFU natively preserves each player’s separate, unmixed track, feeding the Babylon.js 3D Spatial Audio API (PannerNode) for client-side distance attenuation and 3D positioning.
Amended by ADR-167 (2026-08-12), clause 3 — the panner’s input is bounded. The sentence above says client-side attenuation without saying over whose coordinates, and
F-R40-01records that ADR-091’s server-authoritative audibility rule was never applied to player voice. The attenuation may run only over actors in the viewer’s disclosed set; it may never run on a last-known position or on §5.1.1’s advisory position uplink, because a continuous attenuation inverts to a distance (ADR-091 clause 3). Where the speaker is not disclosed the voice is flat, not muffled — muffling is what encodes the distance. The unmixed-track argument for the SFU over an MCU is unaffected and is why this is an amendment rather than a supersession.
Alternatives Considered and Why Rejected
- P2P Mesh: explicitly rejected (§9.6.3). O(N²) room total; infeasible CPU/bandwidth on mobile.
- MCU (mixing): explicitly rejected (§9.4). Individual tracks cannot be recovered after mixing, making spatial audio impossible.
- Routing through the .NET backend: explicitly rejected (§7.1). Binds server cost to client input rate and creates GC spikes.
Consequences (including negative)
- Positive: the backend is physically isolated from high-frequency traffic.
- Negative: SFU relay is billed by the vendor, not a free channel. The “zero bandwidth overhead” ADR-014 once claimed holds only for our own .NET layer. §9.6.3 therefore adds adaptive rates (60Hz for ≤ 6 peers, 20Hz for ≤ 16, 10Hz above), and lists it as an explicit cost item in §10.2/§10.3.
- Negative (ADR-026): WebRTC is unavailable end to end on some corporate and campus networks (UDP blocked, TURN over 443 filtered by DPI). For user-authored content (Yjs), a single transport dependency means data loss, so ADR-026 mandates a SignalR relay fallback.
- Negative (revised by ADR-050): because in-progress positions bypass the backend entirely, the server does not know where an entity is during a drag — and ADR-014’s per-tick outbound allowlist recomputation needs that position to hold. ADR-050 rule 7 therefore requires lease holders to publish an advisory position at tick rate (coalesced into one message per room per tick), and explicitly acknowledges this as a deliberate exception to “zero requests hit the backend”.
- Negative (ADR-038): defining the channel by transport rather than content is why an information disclosure entered that channel (the Keeper’s vision cone). ADR-038 therefore adds a content rule.
Enforcement
- §9.6.3: ephemeral payload types are a positive list, each with a schema, enforced at the SDK/serialization boundary.
- §14.7: every ephemeral message field declares its provenance; any field derived from the visibility set or occluder computation fails the build.
- §14.6: WebRTC/SFU unreachability is an injectable capability veto.
ADR-013 — Hybrid UI Rendering Boundary (World-Space WebGPU vs. Screen-Space DOM)
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: Amended-by 053; interacts with 061
Context
A large battle may have 500 goblins, each with a health bar and a nameplate. Presenting these as HTML DOM costs 500 elements needing repositioning every frame, plus the CPU cost of layout thrashing and depth sorting. Screen-space UI such as character sheets, chat, and turn order is the opposite: it needs genuine text layout, accessibility, and complex interaction, and rebuilding those inside a canvas is a massive regression.
Decision
The boundary is drawn by an element’s spatial belonging, not by technology preference:
- World-space UI (inside the canvas): high-frequency, high-volume UI tracking 3D objects. HTML DOM is abandoned; elements are baked into a dynamic
Canvas2DTexture Atlas and drawn as WebGPU Thin Instances, tens of thousands of elements in a single draw call in 3D space. Depth Write (Alpha Testing) offloads depth sorting to GPU hardware. - Screen-space UI (DOM): SolidJS + Tailwind, 0-VDOM fine-grained signals.
- UI that cannot tolerate a one-frame skew belongs inside the canvas (§9.5 Guardrail 2) — this is the criterion, not a preference.
Alternatives Considered and Why Rejected
- Presenting everything as DOM: explicitly rejected (§9.1.1). Layout thrashing and depth sorting CPU cost for 500 health bars.
- Presenting everything on canvas: implicitly rejected by the three-pipeline classification of §9.1; the accessibility row of §9.5 Guardrail 7 makes it further infeasible (screen-reader-readable DOM is plain text and structured).
- ⚠️ Not recoverable archaeologically: whether a CSS 3D transform hybrid was evaluated is unrecorded.
Consequences (including negative)
- Positive: per-element CPU cost is eliminated at draw time — that is the honest scope of the claim.
- Negative: atlas maintenance is not free. Re-rasterizing changed cells is CPU work for the streaming/render worker, uploads use dirty-rect
copyExternalImageToTexturerather than re-uploading the whole atlas, and re-rasterization happens at most once per frame. - Negative: CJK in world space is a hard problem. MSDF works for enumerable character sets (digits, status icons, Latin letters) but is infeasible for CJK — the platform ships zh-TW, player-chosen names draw from thousands of code points, a fixed MSDF atlas produces tofu boxes, and runtime MSDF generation is too expensive. World-space CJK therefore uses distance-tiered rendering. “A player’s own name displays correctly” is not a feature that can be sacrificed to a rendering strategy.
- Negative (revised by ADR-053): the original topology had all authoritative state reach the DOM via the Render Worker’s ECS. On GPU context loss, a worker busy re-reading/re-decrypting/re-transcoding its texture working set publishes nothing, and the main thread latches the value held at the moment of loss — the UI stays clickable and scrollable while displaying stale state, and a Keeper rules on hit points from minutes ago. That is worse than a frozen canvas. ADR-053 therefore has the Network Worker publish DOM-facing authoritative state on its own channel.
Enforcement
- §9.5 Guardrail 2: exactly one
requestAnimationFramelatch, reading two publication channels. - The value attribution rule: values appearing in both DOM and world space (HP) belong to the Network Worker channel; new authoritative fields declare their channel at review.
- §14.3: a render digest asserts atlas dirty-rect uploads rather than whole-atlas re-uploads.
ADR-014 — Server-Issued Minimized Outbound Allowlist & Targeted WebRTC Routing
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: Amended-by 038, 050
Context
Ephemeral data travels P2P (ADR-012), so every client must know who it may transmit to. The most direct approach is to push the “who can see whom” relationship graph to all clients — and that graph is itself confidential: it reveals the existence of hidden NPCs, the bearing of GM-undisclosed tokens, and the visibility relationships among players.
Decision
The server does not push the “who can see whom” graph. Each client receives only its own minimized outbound allowlist — the set of identities it is currently permitted to transmit to — and zero information about third-party relationships.
- Undisclosed entities never appear in any client’s allowlist input, so the list cannot be mined to infer their existence or bearing.
- The allowlist is recomputed every tick (20Hz), not only when a token is dropped, so a token moved behind cover during a drag stops being transmitted within one tick rather than being trackable for the whole drag.
- Client routing is not a security boundary:
destinationIdentitiesfiltering is a bandwidth and latency optimization performed by the sender and explicitly carries no confidentiality. Any data that must be prevented from disclosure is withheld by the server at the AOI/Visibility Channel stage (§8.2) and never exists on a peer that could leak it. A compromised sender can therefore only over-share its own state, and every receiver discards it under the receiver-side rules of §5.1.1.
Alternatives Considered and Why Rejected
- Pushing the full visibility relationship graph: explicitly rejected. The graph is itself confidential.
- Relying on client routing as a confidentiality mechanism: explicitly rejected, with the correct layering written out (server withholding vs sender optimization).
Consequences (including negative)
- Positive: confidentiality is guaranteed by server withholding, not by client self-discipline.
- Negative (revealed by ADR-050, and this is the most important item in this ADR): per-tick recomputation needs an input the server did not, at the time, possess. The recomputation frequency was never the problem — §7.1 routes in-progress positions entirely around the backend, so the server knows only the pre-lease anchor and filters against a position the entity left seconds ago. A Keeper dragging a hidden creature from a disclosed area into an undisclosed one streams every one of its coordinates — including the final hiding place — to all players for the whole drag, while this document claims exactly the opposite. ADR-050 rule 7 adds the advisory position stream. The lesson: the truth of a guarantee expressed as “recomputed every tick” depends on whether the input that recomputation needs exists, not on the frequency.
- Negative (revealed by ADR-038): the allowlist controls who receives, not what the payload implies. The Keeper’s client legitimately holds the complete occluder set, and the vision cone it computes locally has its shape sculpted by secret doors and hidden creatures, while players are entitled to see the Keeper’s cursor and placement preview. What leaks is not the entity but the shape, and no per-recipient filtering can mask the geometry an outline implies. ADR-038 therefore adds a content rule and withdraws P2P sharing of the vision cone.
- Negative: the “zero bandwidth overhead” claim holds only for our own .NET layer (see ADR-012).
Enforcement
- §14.7: per-viewer filtering is a generative property test — random room states and random viewers, asserting that no entity beyond the disclosure set appears in snapshots, export archives, or the accessibility semantic mirror.
- §5.1.1:
ArchitectureTestsasserts the advisory position type is unreachable from any authoritative path.
ADR-015 — EDoS Defense in Depth Strategy
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: Amended-by 039, 059
Context
For a platform that scales to zero, bills by usage, and has many operations triggerable by unauthenticated or low-trust users, the principal attack surface is not availability but cost: Economic Denial of Sustainability. An attacker does not need to take the service down, only to make it too expensive to sustain.
Decision
Defence in layers, each corresponding to a class of expensive user-triggerable operation. §10.3 currently enumerates six surfaces (originally four).
Alternatives Considered and Why Rejected
⚠️ Not recoverable archaeologically. The original decision’s alternatives are unrecorded.
§ Recorded after the fact (a methodological conclusion established in R2, itself more important than the list): the completeness of the EDoS list equals the completeness of the list of “expensive operations a user can trigger”. This is not a table that can be written once — it must be re-walked whenever a feature is added. The four boundary classes of §6.4 (code entering, data entering, requests we issue on someone else’s instruction, data leaving) plus a release checklist item are the executable form of that conclusion.
Consequences (including negative)
- Positive: cost attacks are handled as a first-class threat model rather than as a retrofitted rate-limit patch.
- Negative (revealed by ADR-039): the original four-surface list missed the Edge Fetch Service — a player-driven server-side fetcher for arbitrary URLs that bypasses every quota in §6.2 (because those hang off presigned direct-upload issuance, and this path does not use it).
- Negative (revealed by ADR-059): the original list missed campaign export. Served from the request layer, two hundred players requesting exports within an hour of a convention would each pin a core for minutes on the same tier serving live rooms, and KEDA’s response — more empty silos — does nothing for an occupied room.
- Negative: the list must be re-walked for every new feature, an ongoing process cost rather than one-time work.
Enforcement
- §10.3: quotas/ceilings for each of the six surfaces.
- Release checklist: whether this release adds any of the four boundary classes.
- §14.6: resource budgets can be set arbitrarily small, so the degradation ladder is reached in seconds rather than four hours.
ADR-016 — State Authority Tiers (T0 / T1 / T2) & Ephemeral Ownership Leases
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: Amended-by 035, 037, 050
Context
Two independent problems share one ADR, which is itself worth recording:
- “Single Authoritative World Model” is only meaningful when exactly one representation is authoritative. The system simultaneously holds Grain memory state, the event stream, and JSONB projections, and without an explicit ordering all three will be used as truth somewhere.
- A single logical entity is described simultaneously by four independent paths: authoritative command validation, the fixed-tick server snapshot, client prediction, and the P2P ephemeral channel. Without an explicit ownership marker, two paths will describe the same field at the same instant.
Decision
Authority tiers (§2.1.1): T0 = the RoomGrain’s in-memory state (authoritative while active; the sole input to command validation and the simulation tick); T1 = the Marten event stream plus snapshots (the durability authority; T0 MUST be exactly reconstructible from T1); T2 = JSONB projections (non-authoritative, eventually consistent by design). No fourth source of truth may be introduced.
T2 Prohibitions (enforced by Descent.ArchitectureTests): read models MUST NOT be used to (1) validate commands, (2) produce a Full Snapshot or Delta, (3) make visibility or permission decisions, or (4) seed a RoomGrain activation. Every T2 payload carries the SourceEventSeq of its projection source.
Ephemeral Ownership Lease (§5.1.1): every replicated entity carries an explicit AuthorityOwner, either Server (default) or Peer:{participantId} holding a lease {leaseId, entityId, ownerId, grantedAtTick, expiryTick}. While held, the fixed-tick snapshot MUST NOT contain a competing transform for that entity.
Alternatives Considered and Why Rejected
- Expressing “no fourth source of truth” through prohibitions alone: proved insufficient in hindsight — ADR-037 pointed out that the tier table states an invariant with no mechanism enforcing it (T0 exactly reconstructible from T1).
- ⚠️ Not recoverable archaeologically: whether lease alternatives (optimistic concurrency control, or server-side authoritative-only with no prediction) were evaluated is unrecorded.
Consequences (including negative)
- Positive: arbitration among the four paths gains a single mechanism.
- Negative (ADR-035): T0 needs a persistent extension — the explored FOW chunk mask. It is not a new tier: it remains recomputable from occluder geometry plus reveal events, and CI asserts the persisted chunk is bit-equal to that recomputation. But “recomputable” is not “cheap”, especially when the recomputation cost is bounded by the number of events since the campaign began rather than since the last snapshot.
- Negative (ADR-037): a snapshot’s
SourceEventSeqMUST never exceed the committed sequence. Without that ordering, a SIGKILL inside the micro-batch window has the room recover from a state whose events do not exist, and every rebuild, replay, and export produces a different world — with no reconciliation event ever triggered, because the server does not know what it forgot. - Negative (ADR-050, five items): lease timing MUST be expressed in tick sequence numbers rather than wall clock (there is no clock synchronization mechanism in the architecture, and a machine whose system clock is five minutes fast would judge every lease expired and discard all peer transforms permanently); grants MUST be pushed immediately rather than waiting for the next 20Hz snapshot; receivers MUST buffer rather than discard packets with an unknown
leaseId(or the first 50–100ms of every drag is discarded); snapshots MUST carry a labelledstaleAnchor(or ADR-023’s per-entity baseline is unsatisfiable for any leased entity); and lease holders MUST publish advisory positions (or ADR-014’s confidentiality claim is false). - Negative (ADR-067): a backgrounded client MUST release its leases immediately — it is not offline, its socket may still be open, and a lease held by a frozen client leaves the entity stuck at
staleAnchorfor the entire room until it expires.
Enforcement
Descent.ArchitectureTests: the four T2 Prohibitions; the advisory position type is unreachable from authoritative paths.- Metrics:
SnapshotSeqLagMUST never be negative;PeerLeaseUnverified. - §14.6: lease expiry and forced handback are injectable faults.
ADR-017 — One Geometry Implementation (Descent.Geometry Rust Crate, Fixed-Point, Dual-Hosted)
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: Amended-by 034, 052, 056
Context
LOS occlusion, FOW mask advancement, and A*/NavMesh pathfinding have at least three potential implementation sites in this architecture: the backend (C#), the frontend CPU (TypeScript or WASM), and the frontend GPU (WGSL compute). Implemented separately, three bodies of code would describe the same capability, and they will necessarily drift — with drift manifesting as two players getting different tactical information about the same wall while every server log looks normal.
Decision
Exactly one implementation: a no_std-friendly Rust crate (Descent.Geometry). The backend hosts it natively over a C ABI; the frontend compiles the same crate revision to WebAssembly. There is deliberately no second C# implementation, no second Rust implementation, and no authoritative WGSL implementation.
Determinism Contract: all position and visibility computation inside the crate uses fixed-point i32.16 integers, never f32/f64. Server and client results are therefore bit-identical across all platforms and vendors — given identical inputs.
Intermediates explicitly widened: the i32.16 coordinate space accommodates an 8km world, but intermediate products (cross products, squared distances) do not fit, and Rust’s release profile wraps silently by default. A wrapped result is bit-identical on both hosts and wrong on both — a long-range sight line reports a non-existent occluder, the hashes match, and there is no reconciliation event, no rubber-band, and no telemetry anomaly. All intermediates are therefore widened to i64/i128; debug and fuzz builds use checked_* arithmetic, and the fuzz overflow count MUST be zero rather than merely low.
Alternatives Considered and Why Rejected
- A second C# implementation (native on the backend): explicitly rejected. “One capability described three times in three languages cannot be kept consistent.”
- An authoritative WGSL/GPU implementation: explicitly rejected. §9.2 gives a further physical reason: compute shaders cannot write to a
SharedArrayBufferand cannot execute JSAtomics, and any GPU→CPU return requires an asynchronousmapAsyncreadback (1–2 frames). Therefore no game logic, event trigger, targeting decision, or visibility decision may consume a GPU result. - Floating point: implicitly rejected by the Determinism Contract; cross-platform floating point is not reproducible.
Consequences (including negative)
- Positive: “the same wall means the same thing to everyone” holds by construction.
- Positive: the crate revision hash is exchanged in the connection handshake; a client whose revision differs from the silo’s is refused prediction and switched to server-driven movement.
- Negative (revised by ADR-034, and this is the most important item): “bit-exact, therefore convergent” cannot be extended to visibility. Identical code converges on identical inputs; the platform guarantees identical inputs only where the client legitimately holds the complete input — and it deliberately does not for visibility, because undisclosed occluders are entirely absent from the client’s data set. The client-computed mask will differ wherever hidden geometry exists, and that correction is a repeatable side channel letting players localize an undisclosed secret door to metre precision by watching the fog snap back. The fix is therefore a scope restriction rather than a better algorithm: no amount of determinism helps when the two hosts are being asked different questions.
- Negative (revised by ADR-056): the agreement of two hosts running the same code proves agreement, not correctness. It is structurally incapable of detecting the wrapping failure above. ADR-056 therefore requires the parity corpus to additionally check against an independent high-precision oracle (the same geometric predicates implemented in rational or big-integer arithmetic, existing only in tests and never shipped). This does not contradict ADR-017: what ADR-017 forbids is multiple implementations on the product path, because they must be kept consistent and will eventually drift; the oracle’s entire purpose is to detect drift, and its scope is limited to predicates.
- Negative (revised by ADR-052): the original shared WASM arena lost its reason to exist once ADR-034 removed client-side visibility computation. Retaining it would mean maintaining and parity-testing two builds (an
+atomicsbinary cannot be instantiated with non-shared memory), when the entire reason the crate exists is to have only one implementation.
Enforcement
- The
geometry_parity.jsongolden corpus runs against four targets: the native host, the WASM build, the GPU visual approximation (tolerance check only), and the independent oracle. - Adversarial case class: identical viewer pose, occluder sets differing by exactly one undisclosed occluder; assert the client build refuses to answer rather than giving a different answer.
cargo-fuzz: numerically degenerate cases against the oracle; overflow count of zero.- Handshake: clients whose crate revision hash does not match are refused prediction.
ADR-018 — Grain Mailbox Discipline
Status: Superseded-by ADR-033 · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: —
Context (retained, because it is still correct)
An Orleans activation processes one message at a time, including across await points. The mailbox is therefore a scarce, latency-critical resource: any operation awaited inside the mailbox occupies the entire room. On that basis ADR-018 forbade three classes of work inside the mailbox: untrusted script execution (§4.3), geometry sweeps (§5.3), and unbounded I/O (cold archive rehydration §7.4, timeline checkout §7.3).
Why It Was Superseded
The diagnosis was correct; the enumeration was incomplete. All three classes ADR-018 lists are “somebody else’s work”, and the fourth class it missed is the tick itself. The simulation stages are sequentially dependent (snapshot visibility filtering consumes the geometry mask), so an AdvanceTickAsync that does await the geometry pool holds the mailbox for the whole of geometry’s duration: roughly 35ms of every 50ms within budget, approaching 100% under pool contention.
The observable symptom is precise and misleading: every player’s actions stop responding while tokens keep interpolating smoothly — because interpolation runs on the client. Engineers go looking for a network problem.
ADR-033 therefore extends the discipline to a fourth class: nothing inside the mailbox may await any dedicated pool, the tick included. The tick becomes pipelined: geometry is dispatched fire-and-forget, tick N assembles its snapshot from the most recently completed mask (usually tick N−1’s), and every snapshot carries both transformTick and maskTick.
Alternatives Considered and Why Rejected
⚠️ Not recoverable archaeologically (for the original decision). ADR-033’s alternatives assessment is in that record.
Consequences (the retained lesson)
A rule expressed as a prohibition list is only as strong as that list is complete — and the item most easily missed is the subsystem the rule’s author belongs to. ADR-018 was written from the perspective of “foreign work polluting the mailbox”, and so could not see the tick. This is the same failure mode appearing a third time, alongside the §6.4 trust tier table missing P4 and the §10.3 EDoS list missing two surfaces.
ADR-019 — Cold Data via PostgreSQL Table Partitioning Instead of Delete-to-Object-Storage
Status: Superseded-by ADR-036 · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: core reasoning retained in 036
Context (retained, because it is still why the design has its present shape)
For extreme database cost optimization, the events of abandoned campaigns need cold tiering. The most intuitive approach is to delete old events and move them to object storage. ADR-019 rejected that, and the reason still holds:
Re-inserting events below the Marten async daemon’s global high-water mark means they are never projected; inserting above it means the whole campaign is applied twice. Both failures are silent, and both surface months later.
The one guarantee event sourcing cannot trade away is that any read model can be rebuilt from the events. Events therefore never leave the database’s logical custody.
Why It Was Superseded
The mechanism is not implementable. ADR-019 partitions on “room activity epoch” — and a PostgreSQL partition key must be an immutable attribute of the row, while a room’s activity is a property that changes over its lifecycle.
Both readings fail, and there is no third:
- Fix the epoch at write time, and it degenerates into “event creation time” — a three-year campaign’s events are spread across three years of partitions, and detaching any one of them takes away part of an active room’s history.
- Make the epoch mutable, and the partition key must be
UPDATEd, which PostgreSQL implements as delete-plus-insert into the new partition — precisely the re-insertion ADR-019 exists to prevent.
ADR-036 therefore partitions on an immutable hash(room_id) instead (a fixed count, a migration-visible constant), with cold tiering per room rather than per partition.
ADR-019’s “no COPY required” claim is withdrawn — room-scoped relocation is COPY-grade I/O. What is retained is the stronger and genuinely load-bearing property: nothing is re-inserted into the active sequence range, and that is the true reason the high-water mark stays valid. The cost moves to an offline worker plus a verified overlap window, rather than disappearing.
Alternatives Considered and Why Rejected
- Delete and move to object storage: explicitly rejected (see Context).
- One partition per room (evaluated by ADR-036): rejected. Tens of thousands of partitions would bloat query planning and catalogue size, and
ATTACH/DETACHon the parent table serialize against each other, making concurrent cold-room wakeups queue up.
Consequences (the retained lesson)
Retiring a decision does not retire its diagnosis. ADR-019’s argument about “re-inserting events below the projection high-water mark” is still the reason ADR-036 has the shape it does. This is the archetypal case for the whitepaper §11 observation that “of five retired decisions, four had a correct problem statement and only the mechanism failed”.
ADR-020 — Branch as a First-Class Data Dimension & Assembly-Independent Upcasting
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: Amended-by 035, 042, 047, 055, 069, 070, 071
Context
Two independent problems share one ADR:
- Marten provides no native stream-fork primitive, and the Git-like multiverse branching of §7.3 needs one. Without an explicit implementation, branching would be “assumed” to exist.
- Ten-year operational stability requires old events to remain deserializable after breaking rules or data structure changes, and without depending on the assembly that wrote the event still being loadable — because that DLL may no longer load on the current SDK major (§3.1).
Decision
Branching: event streams are keyed {RoomId}:{BranchId}; a fork appends a TimelineForked{baseBranchId, baseSeq} event plus a materialized fork-point snapshot as the new stream’s starting point. Zero data deletion — events discarded when a GM triggers Undo are never deleted. Branches have a budget ceiling (default 16) and an explicit GC policy for unreferenced branches.
Three upcasting constraints:
- Upcasters are declarative data transformations registered by
(cartridgeId, eventType, fromVersion)— not code paths inside the writing assembly. - Retired event types have a terminal upcaster converting them to an opaque
RetiredEvent{originalType, payload}envelope. Removing a rule from a cartridge MUST NOT brick every room that ever used it. An unparseable event produces Archive Mode, never a failed activation. - Chains collapse at snapshot time. Because snapshots are written at the current schema version, any upcast chain applied on the hot path is bounded by “events since the last snapshot” rather than “events since 2026”.
Alternatives Considered and Why Rejected
- Assuming Marten provides a fork primitive: explicitly excluded by “Marten provides no native stream-fork primitive”.
- Expressing Undo as reversing events on a single stream: ⚠️ Not recoverable archaeologically, but inferable from the “Zero Data Deletion” requirement and the node-tree UI — marked as inferred.
- Loading the writing assembly in the upcaster: explicitly rejected (§7.1 constraint 1).
Consequences (including negative)
- Positive: unlimited undo, redo, and checkout at any node become properties of the data model rather than retrofitted features.
- Negative (revised by ADR-042): the original rule — “
BranchIdis part of every derived artefact key, and the repository refuses any query omitting it” — has the right goal but an impossible premise: this document itself produces two classes of artefact that cannot carry aBranchId— Yjs documents are deliberately branch-agnostic, and licensed rulebooks belong to no timeline. ADR-042 therefore changed it so that every derived artefact declares exactly one Scope (Campaign/TimelineIndependent/Licence/Global), expressed as a separate repository interface per scope, making omission a compile error rather than a runtime check wherever possible. - Negative (revised by ADR-047): checkout is an unbounded event replay plus N per-viewer filtered Full Snapshots. Executed inside the Grain and described as instant, it lets a GM scrolling the node tree for a restore point — five clicks a second, an ordinary UI gesture — saturate their own room’s mailbox, freeze every player, and stall their own next click. The word “instantly” is withdrawn from this feature.
- Negative (revised by ADR-069/070/071, see §7.5): the three upcasting constraints are correct but insufficient. All three concern keeping old payloads deserializable, and none addresses what a payload means after a rules change, how an attribute’s registry definition is handled once it is removed, or what a timeline checkout across a cartridge major should produce. This gap is entirely invisible until somebody removes an attribute or time-travels — and by then it is a campaign in progress, not a design review.
- Negative (ADR-035):
BranchIdMUST enter the key of explored FOW chunks. Without it, forking to a point before the party explored the east wing would leave the east wing permanently disclosed on the new timeline.
Enforcement
- Registry test: enumerate every derived artefact table and assert each is registered in exactly one scope.
- §14.5: replaying recorded session event streams is the only true test of each upcaster (ADR-065).
- Branch budget and GC policy.
ADR-021 — Dedicated Always-On Projection & Lifecycle Worker with Single-Writer Leadership
Status: Superseded-by ADR-043 · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: —
Context (retained, because it is still correct)
Asynchronous projection needs an owner that exists independently of HTTP traffic. Two failure modes are thereby excluded:
- Zero instances (pure scale-to-zero): a session’s final batch of events is never projected, so search, character sheets, and every other T2 consumer silently fall behind — and a player editing a stale character sheet overwrites content not yet flattened.
- N instances without leases: multiple daemons contend for the same progress rows.
The Marten async daemon, Yjs flattening jobs, room lifecycle/archival sweeps, and LRU/quota sweeps therefore run on a dedicated Descent.Vtt.Worker deployment.
Why It Was Superseded
Two numbers were wrong, and both change the architecture.
minReplicas: 1is not a usable configuration. Single-writer leader election is worth its complexity only when there is a standby to take over; at one replica the platform pays for the election and gets no failover, so one routine ACA node relocation stops platform-wide projection — and the “zero instances” failure above applies in full for that duration. ADR-043 moves tominReplicas: 2(one active, one warm), explicitly acknowledging this as an always-on cost (§10.2).- A global single writer makes rebuilds impossible to isolate. §10.4’s recovery path for a broken projection is “rebuild alongside and swap”; executed by a single global writer, that rebuild competes with every active room’s projection, producing hours of platform-wide T2 lag — which triggers exactly the “stale character sheet overwrite” this ADR exists to prevent. ADR-043 therefore shards the work into independently leased units keyed on
hash(RoomId)and aligned to the §7.4 event partition count, so a shard’s rooms fall in the same partition.
ADR-043 adds one further rule ADR-021 never provided: a rebuild blocks stale writes rather than merely warning — any API accepting a T2 payload from behind the rebuild watermark refuses that edit with an explicit reason. Detection is not protection: exposing SourceEventSeq lets consumers notice the lag; it does not stop a player overwriting content not yet flattened.
Alternatives Considered and Why Rejected
⚠️ Not recoverable archaeologically (for the original decision).
Consequences (the retained lesson)
The “always-on worker” decision was right; its capacity parameters are not decoration on the decision but part of it. minReplicas: 1 and minReplicas: 2 are not two tunings of the same architecture — the former has the same failure mode as having no worker at all.
ADR-022 — Replay/Export Archives Are Server-Generated, Per-Viewer Filtered, Reduced State Deltas
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: Amended-by 059, 060
Context
Post-campaign cinematic replay is a product feature: players download campaign_replay.fbs and play it locally. The most intuitive implementation is to export the raw event stream — and the raw event stream contains the entire history of the GM Channel and Secret Channel: true monster statistics, hidden rolls, undisclosed map regions, private messages. A bulk export hands every secret the GM ever entered to every player, bypassing the visibility model of §8.2 (which filters only the real-time replication path).
A second problem: replay cannot re-derive state from commands, because the rules engine that gives commands meaning is deliberately server-side (§12.2.6) and not shipped to clients.
Decision
- Archives are server-generated, through the same per-viewer filter as real-time replication, scoped to a single requesting identity, with an optional GM “decrypt” toggle (per channel). A GM export and a player export of the same campaign are different files by design.
- The archive contains resolved visible state deltas plus presentation events — what that viewer could observe at the time, ordered by frame. This also makes a filtered export internally coherent: a player sees the damage they took without needing the hidden attack roll that caused it.
Alternatives Considered and Why Rejected
- Bulk-exporting the raw event stream: explicitly rejected. Bypasses the visibility model of §8.2.
- Exporting commands and re-deriving on the client: explicitly rejected. The rules engine is deliberately server-side and not shipped.
Consequences (including negative)
- Positive: “offline replay at zero server compute cost” holds at playback time.
- Negative (revised by ADR-059): generating an archive is neither free nor a single request. It means re-running the entire campaign through the same per-viewer filter as real-time replication, work on the order of the “tens of seconds” of §7.4 (a 300k-event campaign). Served from the request layer, two hundred players requesting exports within an hour of a convention would each pin a core for minutes on the same tier serving live rooms. Export therefore moves to an event-driven Jobs pool and is listed as the sixth EDoS surface of §10.3.
- Negative (ADR-059): an archive MUST record the versions it was resolved with (the
Descent.Geometryrevision and the cartridge version). Re-resolving with current versions produces a file that does not match the players’ memory. Silently re-resolving and presenting the result as a historical record is explicitly rejected: a record claiming to be history when it is not is worse than one that admits its limits. - Negative (ADR-060): because content keys are session-scoped and never persisted (so that revocation works), protected asset bundles are unavailable in network-detached replay, presented as labelled placeholder geometry. “Zero server cost, network-detached cinematic replay” is therefore partially available for campaigns containing protected content — a real reduction in a selling point, and its honest version.
Enforcement
- §14.7: per-viewer filtering is a generative property test covering snapshots, export archives, and the accessibility semantic mirror.
- Export runs in a Jobs pool, with per-account concurrency and daily ceilings.
ADR-023 — AOI Entry Requires a Per-Entity Baseline; Every “Full Snapshot” Is Per-Viewer Filtered
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: satisfied for leased entities by 050
Context
Delta replication is only meaningful when the server knows the client holds some baseline. AOI (Area of Interest) makes the entity set vary as the viewpoint moves — and if “an entity enters a viewer’s interest set” is treated as a side effect of panning rather than as a first-class state transition, that entity’s first packet is a diff against nothing.
Decision
- AOI entry is a first-class state transition. When an entity enters a viewer’s interest set (because the viewer moved, the camera panned, or the entity moved), the server first emits a per-entity baseline before any delta for that entity.
- Every artefact called a “Full Snapshot” in this document — including the forced broadcast after time travel or a fork (§7.3) — is filtered through that viewer’s AOI and Visibility Channels. No unfiltered world snapshot exists on the wire; a single unfiltered broadcast would disclose every GM and Secret channel entity in one packet.
- Every delta carries the
baselineVersionit applies to; a client detecting a gap requests a re-baseline rather than applying the diff to zeroed fields.
Alternatives Considered and Why Rejected
- Treating AOI entry as a side effect of panning: explicitly rejected, with two concrete consequences written out — the entity materializes at the origin with zeroed attributes (displaying as dead at 0 HP), or is discarded and stays permanently invisible, while the server logs look clean.
- Unfiltered global snapshots: explicitly rejected.
Consequences (including negative)
- Positive: the “displays as dead” and “permanently invisible” bug classes are excluded by construction.
- Negative: the per-viewer baseline’s storage layout is normative, not an implementation preference: it MUST be a contiguous version array indexed by dense entity slot, and never a dictionary or boxed values. A dictionary implementation makes each comparison an order of magnitude more expensive, and the delta pass alone would consume the entire Segment B budget of §5.2.
- Negative (made satisfiable by ADR-050): a leased entity’s transform is suppressed from the snapshot (§5.1.1), so a viewer whose AOI takes in that entity mid-drag has nothing to baseline against. The labelled
staleAnchorin thePeerDrivenmarker is that value. Before ADR-050, ADR-023 was unsatisfiable for any leased entity — and that is entirely invisible when the two ADRs are read separately.
Enforcement
Descent.IntegrationTests: an entity entering AOI must receive a baseline first.- §5.2 Segment B benchmark: the cost of per-viewer delta comparison (which decides whether a 50-seat room fits inside the tick).
ADR-024 — Room-Affine Ingress for Tick Broadcasts
Status: Superseded-by ADR-032 · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: diagnosis retained in 032
Context (retained, because it is still why the design has its present shape)
Snapshots MUST NOT travel over a generic SignalR backplane. A Redis/RESP backplane publishes every group message to every silo, each of which then filters — 500 rooms at 20Hz would force roughly 10,000 publishes/sec to be received and deserialized by all 8 silos, the overwhelming majority irrelevant to the receiver, with a single cache container becoming the bottleneck.
This diagnosis holds, and it is why the current design has its shape.
Why It Was Superseded
The prescription is not implementable. “Route all connections for a given RoomId to the silo hosting that RoomGrain” requires the edge layer to know grain placement. Two mutually independent facts defeat it:
- Replicas behind a managed HTTP ingress cannot be addressed individually by an application-level key — the very property §10.1.1 uses to conclude that Cloud Run cannot form a multi-silo cluster.
- Orleans placement is dynamic to begin with (idle deactivation, scale-in, rebalancing), and the weighted placement director of §4.4 decides destinations independently. Any mapping held by the edge layer is stale by construction.
The failure is silent: after a migration, the tick is produced on one silo while the connection is on another, there is no local delivery path, and the backplane is forbidden. Every player’s 3D world freezes while every server signal is green.
ADR-032 therefore moves to two-hop targeted forwarding within the cluster, demoting sticky ingress to a pure latency optimization — correctness MUST NOT depend on it.
Alternatives Considered and Why Rejected
⚠️ Not recoverable archaeologically (for the original decision). ADR-032’s alternatives assessment (a targeted backplane channel, a self-built addressable gateway, disabling placement elasticity) is in that record.
Consequences (the retained lesson)
A routing decision requiring one layer to know a fact decided dynamically by another is stale by construction. And the signature of this failure class is that every health signal is normal — because every component is working correctly, and what is wrong is the assumption between them.
ADR-025 — Lossy-by-Contract Presentation Ring with an Explicit Drop Policy
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: —
Context
The discrete UI event pipeline of §9.1.2 (critical-hit popups, hit sparks, floating numbers) is implemented as a lock-free SPSC queue on a SAB, pre-allocating 2MB with 64-byte cache-line padding to eliminate false sharing. A bounded ring buffer cannot “prevent” overflow, only defer it.
Decision
The policy is explicit rather than emergent:
- The ring buffer is lossy by contract, and carries presentation events only.
- On overflow, drop the oldest and increment the
RingOverflowcounter, which is visible in telemetry and to QA. - Consumers detect gaps by sequence number.
- Any event that must not be missed — death, incapacitation, status change, turn transition — is ineligible for this channel; those travel the authoritative ordered path (§8.2), where gaps are detectable and re-requestable.
A “never miss” guarantee laid over a lossy buffer is exactly how a dead character keeps displaying as alive.
Alternatives Considered and Why Rejected
- An unbounded ring buffer: implicitly rejected by the “bounded” premise; unbounded buffers are not implementable on a SAB, and it would move rather than solve the memory exhaustion problem.
- The producer blocking to wait for the consumer: implicitly rejected by the overall design of §9.1 — the producer is the Render Worker’s CPU-side code, and blocking it would stall the frame loop.
- ⚠️ Not recoverable archaeologically: whether “drop the newest” rather than “drop the oldest” was evaluated is unrecorded.
Consequences (including negative)
- Positive: overflow behaviour is part of the specification, testable, and visible in telemetry.
- Negative: event eligibility becomes an ongoing design responsibility. Every new presentation event MUST be judged as belonging to the lossy or the authoritative channel, and a misjudgement manifests as an occasionally lost critical state change — extremely hard to attribute.
- Negative: the GPU cannot be a producer for this pipeline — compute shaders cannot write to a
SharedArrayBufferand cannot execute JSAtomics. Any GPU-derived value must first go through an asynchronousmapAsyncreadback (1–2 frames) and then be copied in by a worker. The “Visual-Sync Queue” is therefore a CPU-side scheduling queue, and its alignment precision is bounded by that readback latency.
Enforcement
- §14.4: the protocol is extracted and exhaustively interleaved under a deterministic scheduler, asserting four invariants (no partially written block is ever observed; a detected sequence gap corresponds exactly to a real drop; the oldest is dropped and only the oldest;
RingOverflowequals the drop count). - §14.4: the tearing detector runs in every debug build, not only in tests.
RingOverflowenters the §10.4 diagnostic envelope.
ADR-026 — Mandatory Server Relay Fallback for Yjs
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: the relay host, capability-token authorization and rate limiting are in this record’s Consequences
Context
Yjs CRDTs carry user-authored content (NPC journals, tactical whiteboards, shared notes), synchronized over the LiveKit SFU’s WebRTC DataChannel (ADR-012). And WebRTC is unavailable end to end on some corporate and campus networks — UDP is blocked, and TURN over 443 is filtered by DPI.
The consequence of a P2P-only design is precise: a player whose SignalR session is perfectly healthy faces a blank whiteboard, empty shared notes, and — critically — local CRDT updates that are never merged and are silently lost when the cache is cleared, because “on reconnect” never arrives.
Decision
Yjs updates therefore have a durable relay path over the authoritative SignalR channel (batched, writing to the same branch-agnostic blob table), used automatically when SFU connectivity fails or degrades. The SFU remains the preferred low-latency path; it is not the only path. Collaborative state always has a durable server-side home.
Alternatives Considered and Why Rejected
- P2P-only: explicitly rejected (see Context).
- Routing the relay through the
RoomGrain: explicitly rejected. Six players’ strokes — 120–360 updates/sec on the very constrained networks this fallback serves — would enter the mailbox §4.4 protects, rebuilding the load §7.1 moved to WebRTC in the first place.
Consequences (including negative)
Mandating it does not automatically supply the host, the authorization model, or rate limits — all three had to be decided separately, and that is the substance of this record:
- Host: a dedicated Hub endpoint writing directly to the
TimelineIndependentscope blob table, entirely bypassing theRoomGrain. This is legitimate precisely because Yjs is non-authoritative, branch-agnostic, and needs no command validation. - Authorization: bypassing the Grain also bypasses the permission check the Grain would have performed, so the connection first obtains a document write capability token from the
RoomGrain(scoped to aRoomId+ a set ofDocIds, with an expiry), and the relay thereafter validates the token rather than calling the Grain per update. Authorize once, relay many times. - Rate: a per-participant coalescing window, a per-room aggregate ceiling, and a payload size ceiling.
- Observability: the current transport mode (SFU vs relay) is attached to the §10.4 diagnostic envelope — without it, “only this one corporate team gets stuck” is an unattributable ticket, and a degraded path nobody can see is a degraded path nobody will fix.
Enforcement
- §14.6: SFU unreachability is an injectable capability veto; the relay path runs the same test suite as the SFU path.
- §10.4: transport mode is a mandatory field of the diagnostic envelope.
ADR-027 — The Capability × Consumer Matrix as a Release Gate (No Blank Cells)
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: Amended-by 061, 064, 068
Context
A fallback covering only one consumer of a capability is not a fallback. The degradation strategies in this document were originally written per subsystem, so one missing capability could silently break three subsystems that never declared a dependency on it.
Decision
A Capability × Consumer matrix (§9.5 Guardrail 7), normative: every cell MUST read Supported, Degraded (defined behaviour), or Unsupported (defined user-visible behaviour) — a blank cell blocks release.
Session Capability Report: capability detection runs once at startup, resolves the effective Profile, and is attached to every telemetry event and error report. An operator diagnosing a desync MUST be able to see which cells were in effect for that session without asking the player.
Alternatives Considered and Why Rejected
- Per-subsystem degradation strategies: explicitly rejected (see Context). That is what this ADR replaces.
Consequences (including negative)
- Positive: “what happens when a capability is missing” becomes an exhaustible, reviewable question.
- Negative: the matrix grows with the feature catalogue. ADR-061’s “presentation effect availability (per effect × per profile)” row grows with the effect catalogue, and stays manageable only by grouping effects into screen-level/entity-level/audio-level categories.
- Negative (revised by ADR-064, and this is the most important item): “a blank cell blocks release” can be satisfied with a sentence. The missing-capability behaviour each cell describes occurs only when that capability is missing — and on a development machine or a CI runner it never is. The original rule’s actual strength was therefore “somebody wrote down what should happen”. ADR-064 upgrades it to: every cell names a test that forces the condition via a capability veto and asserts the behaviour, and a cell without a test is equivalent to a blank.
- Negative (ADR-068): the new viewport rows in the matrix mean UI descriptors MUST carry semantic role annotations, or the platform cannot fill those cells for community cartridges.
Enforcement
- Release gate: no blank cells, and (after ADR-064) a passing test per cell.
- §14.6: capability vetoes are a first-class component, compiled out of production builds.
ADR-028 — Result-First Deterministic Dice (Result + Shared Physics Seed)
Status: Superseded-by ADR-041 · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: diagnosis retained in 041
Context (retained, because it is still correct)
The authoritative result comes from the server’s commit–reveal CSPRNG (§4.1). A free local physics simulation stopping on a different face is therefore not an option — it forces either a visible post-settle face flip, or a chat log that contradicts the dice.
This diagnosis is right.
Why It Was Superseded
“Issue a physics seed designed to stop on the authoritative face” is not implementable. It requires inverting seed → outcome: 1d100 has 100 outcomes and can be tabulated; but a 10d6 damage pool has roughly sixty million ordered outcomes, rejection sampling needs tens of millions of simulations on average, and §5.2 allocates compute for dice physics nowhere at all.
ADR-041 therefore changes to: an offline, CI-verified trajectory table keyed (dieType, faceValue) with K authored variants, plus a non-overlapping landing slot layout table selected by die count. The server picks a trajectory and a slot per die; the wire carries {dieType, face, trajectoryIdx, slotIdx} plus a flavour seed for camera and lighting. All clients play the same deterministic trajectory, so all viewers see identical motion, and no result was ever re-decided by the animation.
Alternatives Considered and Why Rejected
- Free local physics: explicitly rejected (see Context).
- Inverting the seed (ADR-028’s original mechanism): rejected by ADR-041 for combinatorial explosion (see above).
- Guided canned animations without collision resolution: rejected by ADR-041. Ten dice would intersect each other, trading ADR-028’s visible flip for visible clipping.
Consequences (retained, plus the two costs ADR-041 honestly acknowledges)
- Dice motion is authored, not emergent — variation comes from K variants × slot permutations, and adding a die type or a tray requires a pipeline run.
- Dice cannot interact physically with the map or tokens: they roll in a dedicated tray volume, not on the battlefield.
- The number of simultaneously animated dice has a ceiling, with the remainder presented as grouped results with a shorter animation — the same concurrency ceiling pattern as the §9.4 hardware decode sessions.
ADR-029 — SignalR/WebSockets as the Production Transport Baseline
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: —
Context
WebTransport (HTTP/3) is attractive on paper for real-time games: it offers an unreliable datagram channel and avoids TCP head-of-line blocking. Listing it as an architectural requirement is an easy decision to make.
Decision
The supported production transport is SignalR over WebSockets, with MessagePack/FlatBuffers framing (§8.1). WebTransport is explicitly not mandated, and migration is scoped to a Phase 5 evaluation requiring measured latency benefit to proceed.
Alternatives Considered and Why Rejected
WebTransport (HTTP/3) — three reasons that must be resolved first, stated explicitly:
- SignalR has no WebTransport transport (only WebSockets, SSE, Long Polling), so adopting it means abandoning hubs, groups, and the backplane and reimplementing them.
- It requires end-to-end HTTP/3 plus Extended CONNECT through the Cloudflare edge and ACA ingress, which is unverified on this path and must be proven by a spike before any commitment.
- Its datagram channel would duplicate the role WebRTC DataChannels already hold (the ephemeral data of §9.6.3), reintroducing a second unordered path for the same payloads along with the arbitration ambiguity §5.1.1 exists to remove.
Plus one more: TCP head-of-line blocking is already materially mitigated by the fixed 20Hz tick plus the adaptive interpolation buffer of §8.3.
Consequences (including negative)
- Positive: the transport layer decision has explicit, falsifiable re-evaluation conditions rather than a perpetually pending “consider in future”.
- Negative: if the WebTransport ecosystem matures while SignalR still does not support it, the platform faces a choice between “build our own transport layer” and “forgo the benefit”, and by then the cost of reimplementing hub/group/backplane will only be higher. This is accepted technical debt whose maturity condition is written into the ADR.
Enforcement
- A Phase 5 spike, with measured latency benefit as the criterion.
- §9.5 Guardrail 7: the WebTransport row’s degradation behaviour is “SignalR over WebSockets remains the supported baseline”.
ADR-030 — Cross-Cloud DR as an Explicitly Reduced-Capacity Tier
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: Amended-by 055
Context
Describing cross-cloud disaster recovery as a “hot standby” creates an expectation the platform cannot meet: a true hot mirror needs cross-cloud state replication, bidirectional Orleans cluster membership, and always-on cost equal to the primary cluster — all three in direct conflict with the cost model of §10.2.
Decision
The DR tier is Google Cloud Run, single instance, reduced capacity, and explicitly not a hot mirror: a separate ClusterId, no backplane, a ceiling on concurrent rooms, and cross-silo features disabled.
The ClusterId isolation is the point: it makes the two environments unable to see each other at the Orleans membership layer, so split-brain cannot occur.
Alternatives Considered and Why Rejected
- A hot mirror: explicitly rejected (“Explicitly not a hot mirror”).
- AWS ECS Fargate: explicitly rejected in §10.1 — it lacks native HTTP scale-to-zero. (§13 Phase 5 once mentioned it contradictorily; that contradiction was corrected in R2.)
Consequences (including negative)
- Positive: DR’s capability boundary is declared rather than discovered during an incident.
- Negative: the user experience during DR is explicitly reduced, and this MUST be communicated at the product level.
- Positive (reused by ADR-055): the technique of “isolating by a separate
ClusterIdrather than making a single cluster heterogeneous” is reused directly by ADR-055 for SDK major compatibility environments. The same isolation pattern solves two different problems, one of the few cases in this document of a mechanism being successfully reused, and worth recording.
Enforcement
- The DR environment’s
ClusterIddiffers from the primary’s, asserted in deployment configuration. - §9.5 Guardrail 7 applies equally to the DR tier (the §10 deployment Profile table).
ADR-031 — Day-2 Operability Requirements
Status: Accepted · Date: originally unrecorded (archaeologically reconstructed 2026-08-01) · Links: Amended-by 057
Context
Event sourcing makes authoritative history perfectly auditable — a necessary condition, not a sufficient one. In this architecture, the data determining what players actually saw — client-predicted geometry, the effective capability matrix, WebRTC ephemeral traffic, the visibility allowlist version, projection lag — never reaches the server.
The archetypal support ticket is therefore undebuggable: “three of us saw the door closed and the other three saw it open, for ten minutes.” The event stream would correctly show the door opening once, and everything else is unrecoverable.
Decision
Five hard requirements (not aspirational tooling):
- Session Diagnostic Envelope — each client attaches: the resolved Profile and Guardrail 7 capability matrix results, the
Descent.Geometrycrate revision,protocolVersionand build id, the highest applied snapshot tick andDurableSeq, interpolation buffer depth, reconciliation andRingOverflowcounts, lease acquisition/expiry, the observed client-server tick skew,MaskStaleness, the effective Yjs transport mode, the effective degradation ladder step, and per-plugin budget events. - Per-Viewer Replication Digest — for sampled windows (and on demand for flagged rooms), the server retains a rolling hash of what it sent to each viewer each tick.
- Room Time-Machine for Operators — the GM-facing branch/undo UI (§7.3) is a game feature, not an operations tool. Operators additionally need: replaying a room to any tick as a specific viewer identity, a diff of two ticks, and read-only inspection without reattaching an archived room’s partition to the hot path.
- Rollback Paths, Stated Per Failure Class — immutable events mean “redeploy to fix the data” is never available, so each class has an explicit remedy: a broken rules plugin release → per-room cartridge version pinning plus an operator-authored, operator-applied compensating event mechanism (distinct from a GM’s Undo, and itself auditable); a broken projection → rebuild a versioned projection alongside into a new read model and swap atomically (which remains feasible for every room precisely because events are never deleted); a broken schema migration → forward-only migration with an expand/contract sequence, because a partially applied breaking migration cannot be rolled back against a live event store.
- Cost-Bounded Retention — diagnostic data is sampled, capped, and TTL’d by design, and its retention cost appears in the §10.2 FinOps baseline rather than being discovered as an unbudgeted item.
Alternatives Considered and Why Rejected
⚠️ Not recoverable archaeologically. The original decision’s alternatives are unrecorded. What is certain is that the position “the event stream alone suffices for auditing” was explicitly rejected (see Context), but that is this ADR’s motivation rather than an alternative it evaluated.
Consequences (including negative)
- Positive: diagnostic capability is delivered at the same time as the functionality it diagnoses, rather than after the first incident (§13 Cross-Cutting Workstreams).
- Negative (revised by ADR-057, and this is the most important item): a comparison needs two digests, and the original design specified only one. “Viewer B’s digest diverges from the server’s at tick N” is the correct operator experience, but the client side of §10.4(1) carried only counters and the highest applied tick — nothing to compare against. Worse, this section’s own opening explains that the data determining what players actually saw never reaches the server, so a server-only digest would show all six viewers receiving the same, correct
DoorOpeneddelta and explain nothing about that archetypal ticket. - Negative (ADR-057): the digest MUST cover predicted and authoritative state separately. A digest covering only the final rendered state cannot distinguish “lost packet” from “the client diverged in prediction because it was legitimately not told about an occluder” — and the latter (§9.2, ADR-034) is the most likely cause of two players seeing different worlds.
- Negative (ADR-057): operator tooling MUST classify rather than merely detect: given
(roomId, tick, viewerId), return the server’s sent digest, the client’s applied digest, and a classification — lost packet / prediction divergence / projection lag / capability difference — without reproducing that session. - Explicitly not a basis for anti-cheat: legitimate capability differences produce divergence, so a digest mismatch MUST never be the sole basis for a sanction. It is a diagnostic with false positives by design.
Enforcement
- §14.5: ADR-057’s digest doubles as the test oracle in CI (ADR-065) — production diagnostics and the test oracle therefore cannot drift, because they are the same artefact.
- §10.2: diagnostic retention cost is a row of the FinOps baseline.
Appendix A — Three Things This Archaeology Turned Up
The process of backfilling these thirty-one records itself produced three findings, recorded here rather than scattered among the individual entries.
A.1 A Recurring Failure Mode: An Enumeration Is Complete Only Along the Axis It Chose
Four independent occurrences, all the same shape:
| Source | Enumeration | Axis chosen | What it missed |
|---|---|---|---|
| ADR-018 | three classes of work forbidden inside the mailbox | “foreign work” | the tick itself |
| §6.4 trust tier table | P0–P3, claiming “there is no fourth row” | “what executes” | P4: things we fetch on someone else’s instruction (which execute nothing) |
| §10.3 EDoS | four surfaces | (unstated) | Edge Fetch, campaign export |
| §5.3 FOW ceilings | three “simultaneous hard ceilings” | three independent dimensions | that they actually multiply |
The actionable conclusion from this pattern: any list describing itself as “exhaustive” MUST also write out the axis it classifies by. The four-boundary re-walk procedure of §6.4 (code entering, data entering, requests we issue on someone else’s instruction, data leaving) is currently the only solution written as a process, and it covers only the trust boundary domain.
A.2 The ASCII Diagram in Whitepaper §10.1 Still Cited Two Retired ADRs
The diagram annotated Room-Affine Ingress (ADR-024: sticky by RoomId) and Verified cold backups (ADR-019), both of which have been superseded (032/036), and ADR-024’s prescription has been judged unimplementable. Diagrams are the part of a document most prone to drift, because whoever revises the prose does not necessarily redraw the figure. Corrected in this round.
A.3 ADR-063…071 Have No Complete Records Yet
The nine added after R2 (063–071, see §7.5, §9.7, §14) currently have only §11 index sentences and body-text argument, with no standalone record in this file’s format. The difference from 001–031 is that the body-text argument was written at the time, so the material for Context, Alternatives, and Consequences all exists and is reliable — backfilling is formatting work, not archaeology. That is the next task.