Skip to content

🐙 Descent VTT Enterprise System Architecture Whitepaper

🐙 Descent VTT Enterprise System Architecture Whitepaper

Core Architecture Manifesto:

Single World, Multiple Views

Descent VTT adopts a Single Authoritative World Model. All game states, rules evaluations, spatial relationships, and object interactions reside within a unified 3D backend world representation. Frontend renderers do not possess independent world state; they function strictly as visual projections (Rendering Profiles) of the backend world state.

1. Brand, Vision & Positioning

Descent VTT (Descent Virtual Tabletop) is a high-performance, ultra-low latency, next-generation 3D Virtual Tabletop Platform designed for tabletop role-playing games (TRPGs).

  • Core Technical Positioning: 3D First + Rules First + Server First The platform is architected as an “MMORPG Engine Architecture + Rule-Agnostic TRPG Core Engine + 3D Spatial Platform.” The base platform is entirely rule-agnostic, supporting dynamic plugin modules (“Cartridges”) to seamlessly extend different TRPG systems and visual styles (with Call of Cthulhu 7th Edition served as the first-party reference implementation).

  • Four Primary Target Audiences:

    1. Players: Experience immersive, low-latency TRPG sessions with full 3D perspectives, WebXR support, or simplified 2D tactical projections.
    2. Game Masters / Keepers (GM/KP): Build worlds effortlessly via 3D Kitbashing libraries, assisted 2D map extrusion, and AI-assisted rule retrieval.
    3. Ruleset Developers: Leverage Descent.Vtt.Sdk to build, test, and distribute TRPG cartridges. Signed first-party/partner cartridges load as privileged in-process .NET assemblies (version-isolated via AssemblyLoadContext, which is a versioning boundary and not a security sandbox); unreviewed community rule logic ships as sandboxed script instead (§6.4 Extension Trust Tiers).
    4. UGC Content Authors: Author asset bundles, scenarios, and automation scripts using dual-track editors (SolidJS Signal-driven / Rete.js visual node editor & Monaco code editor).
  • Competitive Matrix:

DimensionFoundry VTTRoll20Fantasy GroundsTaleSpireDescent VTT
Core Focus2D First / Community PluginsCloud WhiteboardRules Automation3D Visual Kitbashing3D First + Rules First
Rule CapabilitiesModerate (Module Dependent)Weak (Sheet-based)High (Hardcoded)MinimalExtremely High (Backend Cartridges)
Rendering ArchitectureClient 2D HTML CanvasClient 2D HTML5Legacy 2D UIStandalone 3D (Unity)WebGPU 3D Spatial World
System PhilosophyClient-centric Web AppWeb WhiteboardDesktop MonolithVisual Tool (No Rule Engine)Single Authoritative World Model + Rendering Profiles

2. Core Architectural Strategy: Single Authoritative World Model & Rendering Profiles

To achieve real-time interactivity with ultra-low latency while eliminating microservice overhead and operational costs, Descent VTT completely rejects microservices and Kubernetes (K8s) in favor of a Modular Monolith with Clean Architecture.

2.1 Single Authoritative World Model

The backend Domain layer maintains only the true physical and spatial world state (e.g., 3D coordinates, zone bounds, light properties). It never retains renderer-specific visual objects (e.g., Pixi Sprites or Babylon Meshes):

// Authoritative Domain World State Representation (Backend)
public record ActorState(ActorId Id, Vector3 Position3D, Quaternion Rotation, Vector3 Scale);
public record LightSourceState(LightId Id, Vector3 Position3D, float Radius, float Intensity, Color Color);
public record StaticObjectState(ObjectId Id, string MeshId, Transform3D Transform);
public record ZoneBoundsState(ZoneId Id, BoundingBox Bounds, ZoneType Type);

2.1.1 State Authority Tiers (ADR-016)

“Single Authoritative World Model” is only meaningful if exactly one representation is authoritative. Three tiers are defined, and no fourth source of truth may be introduced:

TierRepresentationAuthorityPermitted Use
T0RoomGrain in-memory world stateAuthoritative for the live activation. Sole input to command validation and the simulation tick.Validation, tick simulation, snapshot generation.
T1Marten event stream (+ periodic snapshots)Authoritative for durability. T0 MUST be exactly reconstructible from T1 — enforced by ADR-037’s four mechanisms below, whose runtime check is the SnapshotSeqLag metric (it may never go negative).Recovery, replay, audit, branching.
T2Marten JSONB read models / projectionsNon-authoritative. Eventually consistent by design.Lists, search, character sheets, reporting.

T2 Prohibitions (enforced by Descent.ArchitectureTests): a read model MUST NOT be used to (1) validate a command, (2) generate a Full Snapshot or a Delta, (3) make a visibility or permission decision, or (4) seed a RoomGrain activation. Every T2 payload carries the SourceEventSeq it was projected from, so any consumer can detect and surface projection lag instead of silently rendering stale state as if it were current. Detection is not protection: while a projection is being rebuilt (ADR-043), an API that accepts an edit derived from a T2 payload behind the rebuild watermark MUST reject it, because a warning label does not stop a player saving a stale sheet over content that had not yet been flattened.

“No fourth source of truth” needs mechanisms, not just a prohibition (ADR-037). The tier table above states an invariant — T0 MUST be exactly reconstructible from T1 — that nothing previously enforced. Four clarifications close it:

  1. T0 has a durable extension, and it is not a new tier. Explored Fog-of-War chunk masks are T0 state with a persisted representation, keyed (RoomId, BranchId, ChunkId) and written in the same transaction as the snapshot that references them (ADR-035). They remain recomputable from occluder geometry plus reveal events, and CI asserts bit-equality between the persisted chunks and that recomputation — the recomputation is the proof that T1 remains sufficient, not a runtime mechanism. “Visible” state is never persisted.
  2. A snapshot may never lead the log. A snapshot’s SourceEventSeq MUST NOT exceed the highest committed event sequence for that stream, and deactivation order is normative: flush pending batches → confirm commit → write snapshot. The metric SnapshotSeqLag must never go negative. Without this ordering, a SIGKILL inside the micro-batch window leaves a room resuming from state whose events do not exist, while every rebuild, replay and export produces a different world — and no reconciliation ever fires, because the server does not know what it forgot.
  3. The cache is a validated copy, not a representation. Room snapshots in Garnet are a read-through cache of a T1 snapshot, keyed including (BranchId, SourceEventSeq), stored byte-identical to the T1 payload so no second serialization shape can drift out of step (ADR-058), and never written from Grain memory. Any seq or shape mismatch falls through to Marten.
  4. Advisory lease positions are not authoritative in any tier. While an entity is peer-driven, its holder publishes a coalesced tick-rate position so the server can recompute AOI and the outbound allowlist against the real position (ADR-050). That value is used for interest and disclosure filtering only — never for events, triggers, LOS authority, or persistence — and ArchitectureTests asserts its type is unreachable from any authoritative path.

2.2 Rendering Profiles

Frontend renderers function strictly as read-only visual projections of the authoritative world model, adapting dynamically to client hardware capabilities:

graph TD
A[Single Authoritative World Model<br>Backend Authority Domain State] --> B[Profile A: Immersive 3D<br>Babylon.js WebGPU-ready<br>PBR / Lighting / XR / Physics<br>High-End PC / Console / VR]
A --> C[Profile B: Low-End 3D<br>Babylon.js Low<br>No Shadow / PostFX<br>Thin Laptops / iGPU]
A --> D[Profile C: Tactical Projection Mode<br>Babylon Ortho / Pixi Adapter<br>2D Read-Only Projection<br>C-Tactical: tablets, legacy devices<br>C-Companion: phones, sheet-first]
  • Profile A: Immersive 3D Driven by Babylon.js (WebGPU-capable). Features perspective cameras, PBR materials, dynamic shadows, particle effects, 3D physics dice, and WebXR support. Targeted at high-end PCs, consoles, and VR headsets.

    • Renderer Backend Parity Caveat: “WebGPU-capable” is not “WebGPU at feature parity”. Babylon’s WebGPU and WebGL2 backends do not support an identical feature set (post-process chains, some XR composition paths), so Profile A maintains a published backend capability matrix per Babylon release, and any feature not verified on the active backend is disabled rather than silently degraded. A feature is only allowed into Profile A after it is confirmed on the backend that will actually run it (§9.5 Guardrail 7).
    • Disabling a feature is not the same as dropping the meaning it carried (ADR-061). Because Profile C is defined by its asset tier and has no 3D scene to shade at all, “visual parity across profiles” is impossible by construction — and the disable-on-unverified rule above would, on its own, silently remove information from a player’s experience. Presentation effects therefore target semantic parity: each effect declares an equivalent expression per profile (a Sanity distortion is a screen-space post-process on A, a border-tint substitution on B where the post-process chain is unverified, an icon and text cue on C), and per-effect × per-profile behaviour is a blank-blocks-release row in the Guardrail 7 matrix. The failure this prevents is a Keeper saying “you all feel the world twist” while the tablet player has been shown nothing.
  • Profile B: Low-End 3D Driven by Babylon.js in simplified mode. Disables real-time shadows, post-processing, and complex particle systems to minimize GPU overhead. Targeted at thin laptops, integrated GPUs, and ultra-lightweight devices.

  • Profile C: Tactical Projection Mode

    • The Real Constraint: an Orthographic Camera changes framing, not cost — it still submits the same meshes, materials, and texture working set. Legacy tablets are limited by fill rate, draw calls, and VRAM, so a camera change alone rescues nothing. Profile C is therefore defined by its asset tier, not by its projection matrix.
    • Phase 1 (Default): Babylon.js with an Orthographic Camera plus a mandatory reduced working set: baked top-down tiles instead of kitbash meshes, lowest-tier KTX2 mips only, no dynamic shadows, no post-processing, and a hard cap on simultaneous materials.
    • Baked 2D Is a First-Class Pipeline Product (ADR-005): every Kitbashing module and every asset bundle MUST declare a top-down 2D bake (sprite/tile + footprint) produced by the asset pipeline (§6.2) — enforced at registration time, where a bundle without a 2D bake is refused Profile C availability on the same principle by which ADR-068 refuses a UI descriptor with no viable C-Companion rendering (§9.7.4). Bundles without a 2D bake are explicitly unavailable in Profile C and render 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.
    • Phase 2 (Legacy Compatibility): A PixiJS Read-Only Projection Adapter renders those baked 2D tiles — not a projected 3D scene, which PixiJS cannot draw. Strictly reserved for legacy mobile devices and low-tier tablets, and only for content whose bundles carry the 2D bake.
    • The asset tier is only half of Profile C; the other half is the viewport (ADR-066). Everything above is about cost. It says nothing about whether a person can play, and a 6-inch phone and a 10-inch tablet differ on the one question that matters — whether a tactical map and a character sheet can be on screen at the same time. Profile C therefore resolves into C-Tactical and C-Companion as a capability, with its own layout, its own input model, and its own consequences for the lease protocol and room liveness. That design is §9.7, and it is not a styling concern that can be settled later: two of its findings (ADR-067, ADR-068) change server-side behaviour and the SDK contract respectively.

3. Project Directory Tree & Modular Architecture (Git Monorepo)

The backend is built on .NET LTS, using a strict directory structure and .slnx solution files for clean dependency isolation:

📁 DescentVTT/ [Git Monorepo Root Directory (Private)]
├── 📄 DescentVTT.Full.slnx [Global Solution: All projects & open-source tests — this is what CI builds]
├── 📄 DescentVTT.Core.slnx [Core Solution: Server, App, Infra, Domain, Sdk, Protocol, Geometry.Interop + module src]
├── 📄 DescentVTT.Plugin.CoC7e.slnx [Plugin Solution: Sdk, CoC7e, RngKit(src)]
├── 📄 Directory.Build.props [Workspace Specs: net10.0, LangVersion, ImplicitUsings, Nullable]
├── 📄 global.json [Opts the repo into the Microsoft.Testing.Platform test runner]
├── 📂 apps/ [📦 Deployable Applications]
│ ├── 📄 Directory.Build.props [Imports tools/build/Production.props — see the note below the tree]
│ │
│ ├── 📂 vtt-backend-silo/ [The Console]
│ │ ├── 🚀 Descent.Vtt.Server # Presentation: Minimal API, Scalar OpenAPI, FIDO2, SignalR (backplane for low-frequency concerns only — ADR-032)
│ │ ├── 🧠 Descent.Vtt.Application # Application: Orleans Grains (Virtual Actors), RoomTickScheduler, FluentValidation, Mapperly, Polly
│ │ └── 💾 Descent.Vtt.Infrastructure # Infrastructure: Marten (event store / write model), Dapper.AOT over JSONB (read model), Orleans Providers, FusionCache, NetTopologySuite (GIS)
│ │
│ └── 🌐 vtt-frontend-client/ # The SolidJS + Vite client. In this tree since the 2026-08-05 convergence; §14 governs its verification tiers
├── 📂 modules/ [🔗 Shared, Rule-Agnostic Libraries]
│ ├── 📄 Directory.Build.props [Imports tools/build/Production.props]
│ │
│ ├── 🛡️ Descent.Vtt.Domain # Domain: Vogen Strongly-Typed IDs, ErrorOr. References nothing; never exposed to plugins
│ ├── 🔌 Descent.Vtt.Sdk # Contract Layer: [Public NuGet] IRulesetEngine, GameActionContext, SdkVersion
│ ├── 📦 Descent.Vtt.Protocol # Protocol Layer: the .fbs set. Generated sources go to obj/ and are never committed (ADR-092)
│ └── 📐 Descent.Vtt.Geometry.Interop # Native FFI to Descent.Geometry's C ABI: [LibraryImport], the only `AllowUnsafeBlocks` project
├── 📂 core/ [The Proprietary Engines — closed source since ADR-111 (2026-08-08); the directory is the licensing statement]
│ ├── 🎲 Descent.RngKit/ # [Proprietary] CSPRNG Crypto Random + Superpower AST Engine
│ │ └── 📂 src/ — Engine Core · Abstractions (pure contracts) · Mechanics · Serialization; own root Directory.Build.props
│ │
│ ├── 🧪 Descent.Sandbox/ # [Proprietary] Off-Thread Jint Pool, Tick-Relative Budgets (§4.3); own root Directory.Build.props
│ │
│ └── 📐 Descent.Geometry/ # [Proprietary] THE single geometry core: LOS, FOW, A*/NavMesh (ADR-017)
│ ├── 📂 src/ # Rust crate: no_std, #![forbid(unsafe_code)], fixed-point i32.16, panic-free hot path
│ ├── 📂 bindings/ # C ABI (backend native host) + wasm-bindgen (frontend WASM, PRIVATE linear memory — ADR-052)
│ └── 📂 tests/
│ ├── 🧪 parity/ # geometry_parity.json golden corpus: native, WASM, GPU approximation, and the independent oracle (ADR-056)
│ └── 🧪 fuzz/ # cargo-fuzz: degenerate occluders, unreachable goals, ceiling breaches
├── 📂 plugins/ [First-Party Dynamic Game Cartridges]
│ ├── 📄 Directory.Build.props [Imports tools/build/Production.props]
│ ├── 🎲 Descent.Vtt.Plugins.BRP # Basic Roleplaying: the resolution kernel and skill catalog CoC 7e composes over
│ └── 🐙 Descent.Vtt.Plugins.CoC7e # CoC 7e Plugin Cartridge: Dynamically loaded via ALC, depends on Sdk & RngKit
├── 📂 tests/ [🧪 Monorepo Test Engineering Suite — strategy in §14]
│ ├── 📄 Directory.Build.props [Test Specs: xUnit v3 on Microsoft Testing Platform, Shouldly, NSubstitute]
│ │
│ ├── 📂 core/ [Core Tests]
│ │ ├── 🧪 Descent.UnitTests/ # Unit tests for Application logic, Sdk interfaces, and utilities
│ │ ├── 🧪 Descent.IntegrationTests/ # Marten Event Streams, Orleans Grains, & SignalR Hub traffic (Testcontainers)
│ │ ├── 🧪 Descent.ArchitectureTests/ # Architecture Guardrails: Dependency Boundary enforcement (ADR-032, ADR-033, ADR-090)
│ │ ├── 🧪 Descent.ReplayTests/ # §14.5: recorded session event logs replayed to identical projected state; the upcaster corpus (ADR-065)
│ │ └── 🧪 Descent.DisclosureTests/ # §14.7: generated per-viewer filtering property tests; ephemeral payload provenance schema assertions (ADR-038)
│ │
│ └── 📂 plugins/ [Cartridge Tests]
│ ├── 🎲 Descent.Vtt.Plugins.BRP.Tests/ # The BRP resolution kernel against the published results table
│ └── 🐙 Descent.Vtt.Plugins.CoC7e.Tests/ # Dedicated unit tests for CoC 7e cartridge logic
└── 📂 tools/ [Shared Tooling]
├── 📄 build/Production.props # TreatWarningsAsErrors, GenerateDocumentationFile — imported by the three trees above
├── 📄 check-network-boundary.mjs # §14.4: the frontend transport / wire / main-thread import boundaries
├── 📄 generate-protocol-ts.mjs # The TypeScript half of §9.6's one-.fbs-set rule
└── 📂 docs-lint/ # ADR-045's three Enforcement lints over this document

Corrected 2026-08-06 — the tree above describes the repository after the 2026-08-05 convergence, and three of its previous claims had become false. It showed a single src/ tree, three Git submodules under modules/, and no frontend; production code now lives in apps/, modules/ and plugins/, the three MIT repositories are vendored (their contents are in this repository’s history, and their own CI no longer runs — GitHub reads workflows only from the repository root), and the SolidJS client is apps/vtt-frontend-client. Two smaller entries were also stale: the test props named FluentAssertions, which the suites replaced with Shouldly, and Descent.Vtt.Infrastructure named only Marten, which reads as a contradiction against the Dapper references actually present — the pairing is CQRS, Marten owning the write model and Dapper.AOT over JSONB the read model. This is staleness rather than supersession: no decision was reversed, so it is a correction and not an ADR.

The Directory.Build.props indirection is not decoration. MSBuild stops at the nearest Directory.Build.props walking up, so when production code left a single src/ tree those projects would silently have stopped inheriting TreatWarningsAsErrors — and CI builds Release with -warnaserror, so losing it turns a build gate into a no-op with no visible change. The settings therefore live in tools/build/Production.props and are imported by a thin file in each of apps/, modules/ and plugins/. Descent.RngKit and Descent.Sandbox carry their own root Directory.Build.props, which stops the walk: they own their conventions and the workspace does not reach into them.

The frontend is no longer a separate repository, but the consequence the previous sentence carried is unchanged and is not made optional by the move: its verification tiers, its own dependency-boundary assertions, and the two architectural seams they require (§14.3, §14.6) are specified in §14. §14.4 is explicit that those frontend boundaries are still guarded by review rather than by a check, and sharing a repository with Descent.ArchitectureTests does not by itself close that gap.

3.1 Cartridge & SDK Version Boundaries

To prevent core SDK updates from breaking community cartridges, strict Semantic Versioning (2.0.0) is enforced:

  • Major Version Boundaries: The dynamic loader (AssemblyLoadContext) checks the declared SDK version of every cartridge. A cartridge compiled against Sdk v1.x is refused activation on a host running Sdk v2.x. There is no in-process “compatibility sandbox” available on modern .NET, and describing one would be a false guarantee; instead the platform operates a side-by-side compatibility environment pinned to the v1.x SDK for the duration of the published deprecation window.

  • A Room’s Cartridge Set Is What Must Be Admitted, Not Each Cartridge (ADR-055): a GM’s room can hold a first-party cartridge already on v2 and a community cartridge still on v1. Per-cartridge version checking alone refuses that room on both environments — and ADR-020’s Archive Mode does not rescue it, because this is an assembly-loading failure rather than an event-deserialization failure. The player’s experience is a room stuck in “waking up” forever while Guardrail 6’s Edge-Hold retries a 503 that will never clear, which is a paid campaign bricked by a version policy. Therefore:

    1. Compatibility is admitted per room, before the host upgrade. Every room’s cartridge set is evaluated against the target SDK major during the deprecation window, and the GM is warned with the blocking cartridge named — not with a generic incompatibility notice they cannot act on.
    2. Incompatible rooms pin to the compatibility environment, and the room→environment assignment is explicit and visible to the GM rather than an implicit placement outcome.
    3. The compatibility environment is a separate cluster with its own ClusterId (the same fencing approach as ADR-030), not heterogeneous silos inside one cluster. Co-hosting two SDK majors in one cluster would require simultaneous grain-interface version negotiation and a single Marten schema readable by both, while §9.5 Guardrail 6 separately mandates that schema application is a dedicated deployment step — there is no defensible answer to “which major owns the schema”. Rooms do not migrate between the two.
    4. Window expiry degrades, never fails. The incompatible cartridge is disabled and the room opens with that ruleset read-only — history, replay and export all work, new commands are refused with an explicit reason — reusing the same state as a mid-session licence lapse (§12.2.6).
    5. Running two clusters through a deprecation window doubles the always-on floor for that period; it is a time-boxed, plannable cost and appears as such in §10.2 rather than as a surprise.
  • Native Interop Is One Project, For The Same Reason The Crate Keeps unsafe In One Place. Descent.Vtt.Geometry.Interop is the sole owner of the [LibraryImport] declarations against Descent.Geometry’s C ABI, and the only project in the tree with AllowUnsafeBlocks. The Rust side draws the identical line — the geometry crate is #![forbid(unsafe_code)] while its C ABI binding permits unsafe, because a C boundary receives raw pointers whose promises no compiler can check. Concentrating that on both sides keeps the geometry itself in a body of code where the guarantee is absolute, and keeps each interop surface small enough to read in one sitting. The alternative was folding it into Descent.Vtt.Infrastructure, which the tree already named; it was rejected because Infrastructure is the persistence composition root and native interop is an unrelated concern that would have brought unsafe into a project holding fifteen package references.

    • The interop layer may not expose an awaitable geometry call to a Grain. ADR-033 forbids a Grain method awaiting a dedicated pool from inside the mailbox, the tick included, and geometry is dispatched fire-and-forget and consumed one tick later. An FFI wrapper offering Task<T> QueryAsync(...) would read as ordinary async C# and violate that on the hottest path in the system, so the surface is dispatch-now / collect-next-tick: the Grain enqueues and returns, the native call runs on a pool thread, and results are read on a later tick against maskTick. This is recorded here because the constraint is invisible from the C# side — nothing about a Task looks wrong until the mailbox is starved.
  • Core-Engine Security Pinning: Descent.Sandbox, Descent.RngKit and Descent.Geometry implement security-relevant limits. Their status has changed twice, and each change is dated because this paragraph has been wrong about it before: Git submodules until the 2026-08-05 convergence; vendored public MIT modules until ADR-111 (2026-08-08); proprietary core engines at core/ since — owned outright, with the vendoring revisions preserved in ADR-111’s record as provenance. The three rules below survive both transitions unchanged, and rule (1) survives the closed-sourcing deliberately: ADR-111 clause 5 forbids “the source is closed anyway” as a justification, so the engines are still written as if an attacker reads them. What follows was written when they were vendored MIT modules and is kept as the argument it was: The three rules below are unchanged by that, with one qualification stated rather than left to be discovered: rule (2)‘s “minimum revision per release” was a check on a submodule pointer, and a vendored module has none. What replaces it is the recorded vendoring revision plus advisory scanning against it; what is genuinely lost is the upstream repository’s own CI, which went inert at the convergence because GitHub reads workflows only from the repository root. The Rust crate is covered by this repository’s geometry job; RngKit’s Native AOT publish gate and both modules’ release workflows are not, and that gap is open rather than closed. Descent.Geometry was added to this rule on 2026-08-02, before the repository existed — the original enumeration was complete when written and named the two submodules that existed then, which is this corpus’s recorded failure mode #1 arriving on a schedule rather than by oversight. Its security-relevant limits are the geometry ceilings: Q-012 (DYNAMIC_OCCLUDER_MAX) is described in §5.3 as an authoring-driven hard limit the engine may refuse against, and the ceiling-breach behaviour is a cargo-fuzz target. Publishing that ceiling as a crate constant would hand an attacker the exact refusal threshold to calibrate against, so it is host-supplied runtime configuration on the same terms as the other two, and the crate carries no default for it. Fixing this before the repository is public is deliberate: the rule is cheap to honour in a crate’s first commit and expensive to retrofit once a published API has shipped a constant. Three rules follow: (1) all DoS thresholds (AST depth/width, CPU and heap budgets, token-bucket rates) are runtime configuration supplied by the host, never constants published in the open repository, so an attacker reading the source cannot calibrate against the exact limit; (2) CI enforces a minimum submodule revision per release and fails the build on a security-tagged advisory, preventing the security boundary from silently version-skewing behind the host; (3) because of (1), the fuzzing suite must run against the production threshold set in private CI — a fuzz campaign against the open-repository defaults exercises a configuration no deployment uses, and the coverage it reports is coverage of the wrong system.

  • Data Survives the Assembly (ADR-020): A room whose events were written by a cartridge that can no longer be loaded MUST still open — enforced in CI by §14.5’s recorded-session corpus (ADR-065), which replays real streams from every cartridge major still in service with the authoring assemblies absent and asserts identical projected state. Event deserialization never depends on the authoring assembly being present (see the declarative upcasting contract in §7.1). A room with unresolvable event types opens in read-only Archive Mode — history, replay, and export all work while new commands are refused with an explicit reason — rather than failing Grain activation and bricking a paid campaign.

4. Core Engines & Compile-Time Defenses

4.1 Descent.RngKit (Superpower AST Parser & Cryptographic Randomness)

  • Zero-Allocation Hot Path: The AST evaluation inner loop allocates 0 bytes on the steady-state path by renting dice pools from ArrayPool<int>, eliminating GC spikes during high-concurrency rolls. The claim is deliberately scoped to that loop: pool misses, the resulting RngAuditPacket, the emitted Domain Event, and serialization all allocate, and the benchmark suite (Descent.RngKit.Benchmarks) asserts the loop’s allocation budget rather than an end-to-end zero.
  • Source Generators for Performance: The engine utilizes a source-generated JsonSerializerContext for RngAuditPacket serialization to avoid runtime reflection and maximize serialization throughput.
  • Game-System Agnostic: The core engine is unaware of specific TRPG rulesets. System-specific logic (e.g., Hope/Fear, stress pools) is implemented as pluggable evaluators via the Descent.RngKit.Mechanics module.
  • Superpower Combinator: Built with Superpower (TokenListParser<RngToken, Expression>), offering mod(unitParser).Or(unitParser) extension hooks so cartridges can inject rule-specific dice syntax without altering core grammar.
  • Length Truncation & AST Lockouts (ADR-001):
    1. Input string physical truncation (excessively long strings rejected immediately).
    2. AST node bounds locked (depth and width constrained to defend against DoS attacks).
  • UUIDv7 Sequential Indexing: All audit records (RngAuditPacket) use Guid.CreateVersion7(), ensuring sequential B-Tree database inserts and preventing page fragmentation.
  • Verifiable Fairness via Commit–Reveal: A server-side CSPRNG result written to a server-side audit table is not evidence — it cannot distinguish “rolled fairly” from “chosen after seeing the target number”. Every roll therefore uses a commit–reveal construction: the server publishes HMAC(serverSeed, rollId) before the roll is resolved, mixes in a client-supplied nonce, and reveals serverSeed with the result. Three constraints make this a checkable property rather than a description of the algorithm used (ADR-044):
    1. serverSeed rotates per roll. A per-session seed revealed at the first roll makes every subsequent roll of that session predictable, letting a player decide whether to spend a Luck point while already knowing the outcome.
    2. Nonce ordering is normative. The client nonce is accepted only after the commitment is published, and the commitment must have reached at least the roller first. Otherwise the ordering is unverifiable from the client’s side and the construction degrades to trusting the server’s claim that it committed first. The commitment publish rides the roll-initiation ack rather than costing a separate round trip.
    3. Verifiability is scoped to two layers, and the scope is stated rather than implied. Layer 1 (cryptographic, publicly verifiable): any participant recomputes the raw byte stream from serverSeed and the nonce and checks it against the commitment. This needs no rule knowledge. Layer 2 (evaluation, auditable as data): the audit record publishes the inputs, thresholds and outputs actually used — bytes consumed, each die’s face, the target number, the comparison, the resulting success tier — so a third party can check the arithmetic without possessing the algorithm. What is withheld is the code, not the numbers.
    • Why the scope matters: the evaluation function for a licensed ruleset lives server-side by design (§12.2.6), and §7.3.2 relies on that same fact. An unqualified claim that “any participant can recompute the outcome offline” would therefore be false precisely for the premium content where disputes actually arise. The condition of making this claim at all is shipping a public verifier — a small tool taking (commitment, clientNonce, serverSeed, auditRecord) and returning pass/fail. If that tool cannot be shipped, the claim is removed from this document rather than softened.
    • Accepted cost: Layer 2 discloses per-roll rule mechanics to anyone who rolls. A licensor may object, and that objection is a real constraint on which rulesets can be offered — it must be settled contractually before launch, not discovered at it.

4.2 Decoupled Plugins & VttVariableDispatcher

  • Descent.RngKit.Abstractions Extraction: Lightweight contracts (IVariableProvider, ITableProvider) are isolated into an independent interface package.
  • Dispatcher Pattern: Entity attributes are stored as dynamic JSONB dictionaries in ActorEntity.DynamicAttributes. The dispatcher resolves variables in memory via XxHash3 lookups, keeping the host core 100% free of static dependencies on specific rulesets.
  • Namespaced Keys & Collision Safety: XxHash3 is a fast, non-cryptographic hash: an adversarial cartridge can search for a value colliding with a first-party attribute in seconds. Two rules are therefore mandatory. (1) The logical key is {cartridgeId}:{attributeKey}, uniqueness-checked at registration, so one cartridge can never address another’s attributes even by construction. (2) The hash is only a bucket index — every hit must be confirmed by a full key comparison before the value is returned or written. Without both, a malicious cartridge could read a GM’s hidden sanity value or zero it out while every audit record shows a legal operation.

4.3 Descent.Sandbox (Zero-Trust Jint Script Sandbox)

  • The Three Core Zero-Trust Invariants:
    1. No reach to the host: Intrinsics are deep-frozen, the global object is non-extensible, and ShadowRealm is explicitly purged to prevent prototype leakage. No reflection or filesystem access is permitted.
    2. Nothing survives execution: Scripts are relocated into an IIFE at the AST node level (not string concatenation). Engines are scrubbed and recycled after execution, ensuring state does not bleed across Game Tables.
    3. A script asks, never writes: Seams are pure reads (returning frozen copies). All state mutations (Effects) are recorded into a bounded buffer, never executed directly by the sandbox, leaving final authoritative execution to the host.
  • Prewarmed Engine Pool, Off the Grain Thread (ADR-033, superseding ADR-018): Utilizes JintEnginePolicy and a background SandboxPrewarmHostedService to eliminate initialization latency spikes. The pool is a dedicated worker pool that never executes on a RoomGrain thread. The Grain dispatches and returns immediately, holding only a correlation handle; the result arrives later as its own mailbox message. It does not await the pool: an Orleans activation processes one message at a time including across await points, so awaiting a pool from inside the mailbox occupies the room for the pool’s full latency and reintroduces exactly the head-of-line blocking this pool exists to remove. “Off the Grain thread” and “not occupying the Grain mailbox” are two different properties and both are required.
  • Tick-Relative Execution Budgets: Because the simulation tick is a fixed 50ms (§5.2), a per-execution limit equal to the tick would let one script consume 100% of a room’s budget, and ten scripts consume half a second of head-of-line blocking. The enforced limits are therefore:
    1. 5ms soft / 20ms hard CPU per execution. At the soft limit the script is preempted and requeued once; at the hard limit it is cancelled and its Effect buffer is discarded.
    2. 10MB heap per execution.
    3. 25ms aggregate CPU per room per tick across the whole pool. Overflow is deferred to the next tick in FIFO order and surfaced to the author as script:deferred, so a room cannot be starved by one player spamming macros.
    4. Rolling token bucket per player and per cartridge, defeating the “stay just under the single-call limit, loop forever” evasion that a per-call limit alone cannot see.
  • Effects Carry Their Premise, and Applying Them Is Budgeted (ADR-046): because scripts run off the Grain thread, a script reads a frozen copy of tick T while its Effects are applied at tick T+k. §9.5 Guardrail 4 already establishes a causal-premise discipline for client intents; the identical hazard on the server path was unguarded, and that asymmetry was the defect. Four rules:
    1. Seams record the premise, not the author. Every host-provided seam automatically records the (entityId, fieldVersion) pairs a read touched. A script can neither opt out nor forge it, because the record is produced by the seam rather than declared by the script.
    2. A mismatch invalidates the whole Effect buffer atomically, reported as script:stale. Per-Effect discard is unsafe for the reason Guardrail 4 already gives for intents: partial application leaves consequences resolved from a premise the server refused. Concretely, it prevents two players firing the same “if HP ≤ 10 then set 0 and drop loot” macro at a target on 8 HP and killing it twice — while every audit record shows two perfectly legal operations.
    3. Invalidation is never a silent no-op. The triggering player is told the macro did not apply and why. Retry policy is declared by the author in the manifest (onStale: retry-once | fail); the platform never retries indefinitely, which would turn optimistic concurrency into a livelock.
    4. Effect application has its own per-tick cap (§5.2 Table A) with overflow deferred FIFO as effect:deferred. Script execution costs 0ms on the Grain thread; validating and committing what a script produced does not, and an unbounded “bounded buffer” is how a tick overruns.
    • This makes ADR-046 Conditional, not Accepted (ADR-074). Rule 2 is only workable if authors can declare a minimal premise, which requires field-level version granularity rather than entity-level — otherwise every macro touching a combatant conflicts with every other during a busy round. That substrate is an open gap (§8.2 item 6), so an ADR whose own consequences say this only works once X exists, where X is unbuilt, does not have a settled status. The regime until then is declared rather than left for an implementer to discover:
      1. Premises are recorded at entity level.
      2. Rule 2 is not relaxed. What degrades is granularity, never atomicity — partial application is the failure that lets two macros kill an 8 HP target twice with two perfectly legal audit records, and it stays forbidden. An ADR must not leave its own safety core on the path of least resistance under deadline.
      3. The observable cost is named and measured: script:stale rises during busy rounds. EffectBatchStale is therefore an expected value in this period, not an anomaly, and its baseline is reset when the substrate lands — otherwise that release reads as a regression.
      4. The mitigation is a narrower read, not a looser check. The seam records the entities a script actually touched, so a macro reading only its own actor conflicts with nothing. “Read only what you need” is a first-class SDK instruction, not a style note.
    • The field-level substrate is one workstream with one owner, and it is the same one §8.2 needs. It serves both the premise versions here and the per-field visibility model there, and the two must not be built separately: built apart, the first would pick a field-identity scheme adequate for numeric attributes and inadequate for relations and sets, and that choice is near-impossible to revisit afterwards. It is listed in §13 as a cross-cutting workstream whose completion simultaneously lifts ADR-046’s condition and closes §8.2 item 6.
  • Interruptibility Contract: every host-provided seam is required to be cancellation-aware, and all regular-expression evaluation executes with an explicit Regex match timeout. Without this, the CPU limits above are enforceable only at statement boundaries and a single catastrophic backtrack escapes them.
  • Hard Defenses & Fuzzing: Tested with SharpFuzz and custom dictionary files (jint_vtt.dict) across millions of iterations. Budget thresholds are runtime configuration rather than constants published in the open-source repository.

4.4 Descent.Server (Virtual Actor Model & Concurrency)

To eliminate the severe race conditions inherent in multiplayer state management, the Application layer abandons traditional stateless Web APIs in favor of the Microsoft Orleans Virtual Actor Model.

  • Single-Threaded Execution: Campaign rooms (RoomGrain) and complex entities are modeled as Grains. Orleans guarantees that only a single thread executes within a Grain at any given time. This completely eliminates the need for manual lock statements, SemaphoreSlim, or Redis distributed locks, ensuring absolute concurrency safety.
  • Mailbox Discipline (ADR-033, superseding ADR-018): Because an activation processes exactly one message at a time — including across await points — the Grain mailbox is treated as a scarce, latency-critical resource. Four classes of work are structurally forbidden inside it: (1) untrusted script execution (§4.3), (2) geometry sweeps (§5.3), (3) unbounded I/O such as cold-archive rehydration (§7.4) or timeline checkout (§7.3), and (4) await-ing any dedicated pool at all — the tick included. Each is dispatched while the Grain holds only a state flag (e.g. Hydrating, TimeTraveling) and answers immediately.
    • The fourth class exists because the tick previously violated the first three. The simulation stages are serially dependent (snapshot visibility filtering consumes the geometry mask), so an AdvanceTickAsync that awaited the geometry pool held the mailbox for the geometry duration: roughly 35ms of every 50ms at budget, and approaching 100% under pool contention. The observable symptom is precise and misleading — every player’s action stops responding while tokens continue to interpolate smoothly, because interpolation runs on the client. The tick is therefore pipelined: geometry is dispatched fire-and-forget and tick N assembles its snapshot from the newest completed mask, normally tick N−1’s (§5.2).
    • The 5ms rule and its one exemption. Any Grain method on the request path whose p99 exceeds 5ms is a CI-tracked regression. AdvanceTickAsync is explicitly exempt with its own published SLO, because §5.2 allocates it two segments totalling more than 5ms. Stating the exemption is the point: as previously written the rule was violated four- to nine-fold by the tick itself, which makes it unexecutable in CI and therefore not a rule at all.
  • Load-Aware Placement: To prevent multiple high-load rooms (e.g., massive 50-player campaigns) from being allocated to the same container and fighting for CPU, RoomGrain uses a custom weighted placement director, not [ActivationCountBasedPlacement]. Activation count is not load: with 3 heavy rooms and 300 solo rooms, count-based placement sends every new activation to the emptiest silo — which after a scale-out is the newest replica — and can co-locate all three heavy rooms there. The director places on a room weight published to the silo, with an admission ceiling per silo expressed in weight rather than activations. Placement operates across the silo cluster of the Modular Monolith (ADR-002); this is a single deployable scaled horizontally, not a microservice fleet.
    • Room weight is an expected cost, and a per-room budget ceiling is not a weight unit. The distinction is load-bearing for capacity planning: the per-stage ceilings in §5.2 exist to isolate pathological rooms, and summing them describes a room that never occurs. Weight is therefore derived from expected cost as a function of viewer count — which is the dominant term, since per-viewer snapshot assembly outweighs world geometry by more than an order of magnitude at 50 seated players — with an explicit safety factor applied at admission. Conflating the two over-provisions hardware by roughly an order of magnitude and makes the §10.2 cost model unusable.
  • Serverless Clustering & Graceful Shutdown: To prevent “split-brain” issues during the violent scale-up/scale-down churn inherent to serverless, Orleans utilizes ADO.NET Clustering (via Neon PostgreSQL) to provide a strongly consistent membership table. During unpredictable Scale-to-Zero events (SIGTERM), the system utilizes .NET IHostedLifecycleService to intercept termination signals. The Orleans RoomGrain is granted a 30-second grace period to reject new requests and forcibly flush its final in-memory state snapshot to the Marten database.
    • Honest Durability Statement: this guarantees zero data loss on graceful shutdown. It does not cover SIGKILL, OOM, or node loss, where up to one micro-batch window (§7.1, ~50ms) of already-generated events can be lost. Because a client must never treat a non-durable result as final, intents are acknowledged with a DurableSeq that only advances after the Marten write commits; on reconnect the client presents its highest DurableSeq and the server force-corrects any state the client optimistically confirmed beyond it. Without this handshake a crash produces a silent, permanent divergence that no reconciliation event ever fires for, because the server does not know what it forgot.
    • DurableSeq Runs Both Ways (ADR-037). The clause above constrains only the client. The server is constrained symmetrically: it never resumes from state it cannot prove is committed. A snapshot’s SourceEventSeq may not exceed the highest committed sequence, and the deactivation order is normative — flush pending batches → confirm commit → write snapshot → release the activation. Without the second direction, a SIGKILL between a periodic snapshot write and its batch commit leaves the room resuming at sequence X while the log ends at X−k: the live room and every rebuild, replay and export then describe different worlds, each internally consistent, with no detector and no correction path. The 30-second grace period budgets all three steps, and a failed snapshot write is a metered, non-fatal outcome (the next activation simply replays more events), never a lost room.
    • Snapshots are captured on the Grain thread and written off it. Rule (4) of the mailbox discipline forbids awaiting I/O inside the mailbox, while the ordering above requires the write to follow a commit confirmation. Both hold by splitting the operation: the Grain produces an immutable capture plus its sequence and hands it off without awaiting; a writer waits for that sequence’s commit and then persists. The deactivation path is the single documented exception where the Grain may wait, because at that point no tick or intent is competing for the mailbox.
    • Stale Membership Hygiene: ungraceful termination leaves rows in the ADO.NET membership table; a cold-starting silo must vote them dead before serving traffic, which is a measured component of the cold-start budget (§9.5 Guardrail 6), not a free operation.
  • Engine Wrapper Pattern & Trust Tiers: The RoomGrain orchestrates the core engines but does not host them. A cartridge’s AssemblyLoadContext is created once per cartridge version per silo — never per intent and never per room — and is reference-counted for collectible unload, with leaked-ALC detection surfaced as an operational metric. Jint macros are dispatched to the sandbox pool (§4.3). Critically: AssemblyLoadContext is a versioning and unload boundary, not a security boundary. A loaded .NET cartridge runs in-process with full host privileges (no CAS/AppDomain sandbox exists on modern .NET), so only signed, reviewed first-party/partner cartridges may be loaded — see the Extension Trust Tiers table in §6.4. Community-authored rule logic is never delivered as a .NET assembly.
    • When the reference count reaching zero actually unloads is Q-097, not “immediately” (ADR-134). A cartridge lingers loaded for that window after the last room releases it, because Orleans deactivates idle rooms routinely and an immediate unload makes a silo re-read, re-verify and re-JIT the same cartridge continuously — and because Unload is asynchronous, leaving several generations of one cartridge alive at once. The room holds the cartridge through a lease whose disposal is the decrement, so a held reference cannot outlive the release that was meant to drop it. The leaked-ALC operational metric named above is not yet built.
  • CQRS Orchestration: The Grain acts as the definitive Command Validator. Upon successful execution of the Sandbox/RngKit logic, the Grain outputs Domain Events directly to the Marten Event Store.

5. World Simulation Engine

The World Simulation Engine sits between the Domain layer and the Rendering profiles to govern the physical 3D world state.

5.1 Simulation Authority Boundary (ADR-007)

Architectural Boundary Rule:

Renderers MUST NOT mutate Domain World State directly.

Enforced by type and by two import graphs (ADR-089). Renderers receive IReadOnlyWorldState or immutable snapshot structures, so the prohibited operation is not available to be called rather than being available and forbidden — the same move ADR-042 made for scope confusion and ADR-082 for entitlement. NetArchTest in Descent.ArchitectureTests fails the build if any renderer-facing or presentation type depends on a domain write or update method. The rule is two-sided because the renderers it names — Babylon.js and PixiJS — are frontend TypeScript, which a .NET architecture test cannot reach: §14.4’s dependency-cruiser list accordingly carries the matching assertion that no renderer module may import the command or mutation surface. A .NET-only check would produce a green build constraining the wrong codebase, which is worse than the original gap because the ledger would show it closed.

All frontend renderers (Babylon.js / PixiJS) operate strictly as read-only views. All player interactions (moving tokens, opening doors, toggling lights, applying damage) must dispatch Intent Commands (e.g., MoveActorCommand, ToggleLightCommand) to the Application/Simulation Layer for validation and execution.

graph BT
A[Rendering Profiles: Babylon.js / PixiJS<br>Read-Only Views] -- User Action --> B[SignalR / WebSockets<br>see ADR-029]
B -- Dispatch User Intent --> C[Orleans RoomGrain<br>Single-Threaded Validator]
C -- Validate & Generate --> D[Domain Events<br>ActorMovedEvent, DamageAppliedEvent]
D -- Append to Event Store --> E[Marten / PostgreSQL]
E -- Project to Read Model --> F[Read Model<br>Non-Authoritative JSONB Projection<br>see §2.1.1 Tier T2]

5.1.1 Single-Writer Arbitration & Ephemeral Ownership Lease (ADR-016, ADR-050)

A single logical entity is described by four independent paths: authoritative command validation, fixed-tick server snapshots, client-side prediction, and the peer-to-peer ephemeral channel. To guarantee that two paths never describe the same field at the same instant, every replicated entity carries an explicit authority owner.

AuthorityOwner is either Server (the default) or Peer:{participantId} holding an Ephemeral Ownership Lease {leaseId, entityId, ownerId, grantedAtTick, expiryTick}.

1. All lease timing is expressed in tick sequence numbers — never wall clock. A client evaluates expiry against its own highest applied transformTick. This is not a style choice: the receiver rule below requires every client to judge whether a lease is currently held, and no clock-synchronisation mechanism exists anywhere in this architecture. Against absolute timestamps, a machine whose system clock is five minutes fast — an ordinary condition, not an edge case — would judge every lease expired and permanently discard all peer transforms, so that one player alone sees every other player’s drags as 20Hz server-driven stutter while everyone else is smooth. Tick sequence numbers are a monotonic time base both sides already share.

2. Lease acquisition is a Command, and the grant is pushed immediately. The RoomGrain grants the lease when a drag / draw / dice interaction begins. A client that fails to acquire one does not predict; it falls back to server-driven movement. The grant is a low-frequency control message and is pushed on the authoritative channel at once, not held for the next 20Hz snapshot — which reduces the start-of-drag race from “RTT plus tick quantisation” to “RTT”. The per-tick PeerDriven marker in the snapshot is then an idempotent confirmation of current state, not the notification.

3. Receiver rule: grace-buffer, do not discard. A client MUST NOT render a peer transform for an entity whose lease it does not observe as held by that peer. But discarding such packets — the naive reading — throws away the first 50–100ms of every drag, because peer transforms travel WebRTC while the marker travels the tick, and the faster path arrives first. Unknown-leaseId packets are therefore buffered, not rendered (bounded: ≤150ms or a small packet count), drained when the grant or marker arrives, and dropped with a PeerLeaseUnverified metric if it never does. The confidentiality property is untouched — nothing is rendered before authorisation — while the visible stutter at the start of every single drag disappears. Peer packets carry {leaseId, seq}; seq is a per-lease monotonic counter, because the ephemeral channel is unordered and duplicates must be discardable.

4. Snapshot suppression, with a labelled anchor. While a lease is held, the fixed-tick snapshot (§8.3) MUST NOT contain a competing transform for that entity. It transmits PeerDriven{leaseId, ownerId, expiryTick, staleAnchor}, where staleAnchor is the entity’s last authoritative position and is explicitly labelled as such. This is not a second opinion about where the entity is now — it is where the entity will return to if the lease expires unfinalised, which is precisely the value a client with no peer stream should draw. Clients with peer data render the peer stream; clients without (WebRTC unavailable per §9.5 Guardrail 7, or an entity that entered their AOI mid-drag) render the anchor with a “being moved by {owner}” affordance. Without the anchor, those clients have no position at all for the duration of a renewable lease, and ADR-023’s per-entity baseline requirement is unsatisfiable for any leased entity.

5. Hand-back guarantees visual continuity, not a time bound. The transform is T0 state, and §2.1.1 makes T0 authoritative for the live activation — so resuming its broadcast does not require durability. On FinalizeMove, the Grain validates against T0 and geometry, updates T0 and reclaims ownership immediately, and the next tick carries the authoritative transform. The Domain Event enters the micro-batch as usual and DurableSeq advances on commit (§4.4), unchanged. Sequencing the broadcast behind durability instead would put batch flush plus commit plus tick quantisation — on the order of 100ms — between the last peer value and the first server value, which is three to four times a fibre client’s 30ms interpolation window and would put a visible hitch in every drag on the best networks.

  • The guarantee offered is therefore no gap and no rewind: the peer sends a terminal packet marking its final value, receivers hold it until the first server transform arrives and cross-fade over one interpolation window. If the terminal packet is lost, the fallback is the marker disappearing from the next snapshot. Because the authoritative position equals the peer’s final value for any legal move, the cross-fade displaces nothing. Rubber-banding is the rejection path, not the steady state.
  • Distinguish this from ADR-037. Broadcasting current T0 is not “resuming from uncommitted state” — that rule governs activation after a crash, and the client side of it is handled by the DurableSeq force-correction. The two rules govern different moments.

6. Renewal, and what expiry means. The holder renews automatically while the interaction is live (every ~500ms), so the 2s expiry window fires only on genuine connection loss. On expiry without finalize the server reclaims and resumes from staleAnchor — the entity returns to its pre-drag position, which is correct because the drag was never committed, and must be rendered as a deliberate return with a brief explanation rather than as a glitch. The server may force-expire (GM override, entity destroyed, room paused, timeline checkout per §7.3), and a forced expiry is pushed immediately rather than waiting for the tick.

7. The holder publishes an advisory position, because otherwise disclosure filtering cannot work. §9.6.3 recomputes each client’s outbound allowlist every tick specifically so that an entity moving behind cover mid-drag stops being transmitted within one tick. That is impossible on the ephemeral path alone: the server knows only the anchor, so it would filter against a position the entity left three seconds ago — and a GM dragging a hidden creature from a revealed area into an unrevealed one would broadcast its every coordinate, including its final hiding place, to every player for the whole drag. Lease holders therefore publish their in-progress position to the server at tick rate, coalesced into one LeasePositionsUpdate message per room per tick rather than one message per drag.

  • This value is advisory and non-authoritative by construction: it is used for AOI and allowlist recomputation only, and never for events, database writes, trigger evaluation, or LOS authority (triggers along a lease path are evaluated against the server-issued authoritative path — §5.4.3). ArchitectureTests asserts its type is unreachable from any authoritative code path.
  • It is also a deliberate, stated exception to §7.1’s “zero requests hit the backend” for the ephemeral path: 60Hz cursors and peer transforms genuinely bypass the backend, but one coalesced 20Hz message per room does not. That exception is the price of §9.6.3’s confidentiality claim being true.

5.2 Decoupled Simulation Ticks

  • Simulation vs. Rendering: World simulation updates at a fixed 20Hz Tick Rate (50ms — Q-001) on the server, while client renderers run independently at native hardware refresh rates (e.g., 60FPS to 144FPS; a property of the client’s display, not a platform quantity). The tick rate is a fixed architectural constant, not a per-deployment tuning knob: every budget below, the interpolation buffer in §8.3, and the script limits in §4.3 are derived from it.
  • Tick Ownership (ADR-033): The tick loop does not run inside the RoomGrain’s request mailbox. A dedicated RoomTickScheduler drives the cadence and delivers a single AdvanceTickAsync message per tick, so a queued player intent, a script, or a slow I/O call can never push the tick behind unrelated work.
    • The scheduler is state-driven, not an unconditional timer (ADR-072). A room is in one of three states, and only the first ticks:

      StateConditionTickBranch keep-aliveGrain
      Active≥ 1 foregrounded client20Hzonactivated
      Dormant0 foregrounded, ≥ 1 connected, and no pending worksuspendedheld Q-052, then releasedactivated
      Deactivated0 connectedoffOrleans default

      Suspending is not a fidelity degradation, and the distinction is the whole point. The rule above — Q-001’s 20Hz is a constant that is never silently halved — governs a room in play. A room with zero viewers has nothing to simulate, because the tick’s output is a per-viewer snapshot and the viewer count is zero. That is nothing to do, not doing it worse, so it is recorded with its own vocabulary and its own metric (RoomDormant, never TickOverrun). A reduced cadence was rejected for a sharper reason than cost: suspension has a clean resumption point — ADR-067’s per-viewer resync — and a slowed cadence has none, while draining every client’s interpolation buffer for the reasons §8.3 already gives.

    • Three constraints make this safe. (1) Entering Dormant requires a Q-051 grace window after the last client backgrounds, because everyone checks their phone for a minute and everyone left four hours ago must be distinguishable, and without a window they are identical. (2) “No pending work” is a hard condition, not a description — a non-empty Effect queue, a scheduled trigger, or an in-flight hydration/checkout/export saga keeps the room Active, because a script’s deferred Effect does not stop needing to be applied just because nobody is watching. (3) An unknown foreground signal counts as foregrounded, extending ADR-049’s fail-safe to a new signal for its original reason: over-paying is recoverable, suspending mid-combat is not.

  • The tick is pipelined; geometry is consumed one tick late. Geometry is dispatched fire-and-forget and publishes into a sequence-numbered double buffer; tick N assembles its snapshot from the newest completed mask, normally tick N−1’s. Every snapshot therefore carries both transformTick and maskTick, and §8.2 permits maskTick to lag by one. This has a gameplay-visible consequence that must be handled in the renderer, not hidden: an entity could otherwise be drawn one tick before the fog that conceals it, so a client gates entity disclosure on the older of the two ticks. The alternative — awaiting geometry inside the tick — occupies the Grain mailbox for the geometry duration and is forbidden by §4.4 rule (4).
Table A — Mailbox occupancy per 50ms window (the real constraint)

The previous version of this table described the tick as a sequence of stages summing to Q-001’s 50ms. That model does not match how Orleans schedules work: the tick and each player intent are separate mailbox messages contending for one single-threaded activation, and Orleans has no priority mailbox. The quantity that actually matters is total occupancy per window — and because the tick message queues behind whatever is already enqueued, occupancy is the jitter budget.

WorkMessageBudgetNotes
Tick — Segment A: apply queued Effects (with premise validation, §4.3), generate Domain Events, enqueue to the batch channelAdvanceTickAsync (first half)≤ 3ms (Q-003)Derived from the Effect-application cap (Q-007); overflow deferred FIFO as effect:deferred. Measured 2026-08-13 (BENCH-01): a full Q-007 batch costs 0.670ms, 4.5× inside this budget
Tick — Segment B: AOI filtering, per-viewer delta, FlatBuffers assembly, per-silo grouping (§8.3), per-viewer digest (§10.4)AdvanceTickAsync (second half)≤ 5ms (Q-003)Derivation below. Measured 2026-08-13 (BENCH-03): 0.675ms at the reference room, 7.4× inside this budget — and three of the seven derived terms measure nothing because the work does not exist
Player intents: validation, event generation, lease grant/renewseparate messagesp99 ≤ 5ms each (Q-004, §4.4); ⊙ ≤ 15ms aggregate (Q-005)Aggregate follows from the per-player intent rate cap (Q-008)
Housekeeping: lease expiry, connection-registry updates (§8.3), chunk-flush completions, script-result callbacks, snapshot capture (§4.4)separate messages⊙ ≤ 5ms aggregate (Q-006)
Reserve: GC, Orleans scheduling overhead, intent bursts, tick jitter absorption≥ 22msThe remainder, Q-001Q-002; it is what the other five rows leave, not an independently chosen figure
Total occupancy≤ 28ms (Q-002) / 50ms (Q-001) (56%)Against ~35ms (70%) previously — and the previous figure excluded four workloads mandated elsewhere in this document

Segment B derivation → Q-003 (reference room: 50 viewers, 200 entities in each viewer’s interest set). AOI queries ⊙0.25ms · per-viewer delta comparison ⊙0.40ms · FlatBuffers assembly ⊙2.00ms · cartridge MessagePack sub-payloads ⊙0.30ms · per-silo grouping ⊙0.02ms · per-viewer digest hashing ⊙0.02ms · allowlist recomputation ⊙0.05ms → ⊙3.04ms against a 5ms budget. Measured 2026-08-13 (ADR-177, BENCH-03): 0.342ms · 0.354ms · 0.034ms for the three implemented terms → 0.675ms, which is 0.22× the derivation. The derivation is retained above rather than replaced, because the measurement’s value is the comparison: the budget holds, and it holds by four and a half times more than the arithmetic that justified it. The remaining three terms are not measured because the work does not exist — there is no bucketing step in RoomSnapshotDispatcher, no digest anywhere in the silo, and §9.6.3’s pairwise mask is unimplemented — so their ⊙0.09ms is a claim about code nobody has written, and it is left standing rather than approximated. Per-viewer marginal cost is Q-110; the seat count this budget alone admits is Q-111. Two normative requirements fall directly out of this, because the budget does not hold without them:

  • Per-viewer baselines are stored as contiguous version arrays indexed by a dense entity slot, never as dictionaries or boxed values. A dictionary implementation raises the per-comparison cost by an order of magnitude and the delta pass alone consumes the entire segment.
  • A cartridge’s MessagePack sub-payload is serialised once per entity per tick and its bytes shared across every viewer packet that may see it. Otherwise the cost is O(viewers × entities) rather than O(entities), and 50 viewers pay 50× for identical bytes.

Per-player intent rate caps are derived from this table, not chosen → Q-008, against Q-005. At 10 intents/sec sustained (burst 20), 50 seated players produce ~25 intents per 50ms window; at ⊙0.3ms each that is ⊙7.5ms, inside the 15ms aggregate. Raising the seat count therefore requires lowering the per-player rate or making intent handling cheaper — the trade is explicit rather than discovered under load.

Table B — Off-room pool capacity (per room per second, shared across rooms)

Pools serve every room on a silo, so their budgets are expressed per second rather than per tick. Each entry is an isolation ceiling, not an expected cost — the distinction matters because summing ceilings describes a room that does not occur, and using that sum as a capacity unit over-provisions hardware by roughly an order of magnitude.

PoolCeilingExpectedNotes
Geometry (LOS / FOW advancement / A* / trigger sweeps)≤ 300ms CPU (Q-015a)measured 2026-08-02 — Q-015b’s ⊙3ms is refuted. One fog advance over a 256×256 chunk costs 0.27ms at 0 dynamic occluders and 41.71ms at Q-012’s ceiling of 512; advancing one chunk per tick is 5.4–834ms per room-second, so the 300ms ceiling is breached at 128 occluders by a single observer. LOS and A* are not significant by comparison (0.4µs per ray; 0.58ms for a 121×121 search). See Quantity_Registry.md → Geometry cost. Q-012 reduced to 256 on 2026-08-02 and the mask cadence ladder made enforceable: an advance is then 29.4ms, so Q-015a ÷ cost = 10 advances per room-second for the whole room, admitted by a per-room token bucket at dispatch. Q-015a was not raised — an isolation ceiling raised to fit its load stops being one.Off the tick’s critical path (ADR-033); falling behind raises MaskStaleness, it does not overrun the tick. Measured with the loop running: a room carrying 41.71ms of advance per tick showed a 28µs tick body and zero overruns, which is why an estimate wrong by 278× cost nothing at runtime.
Sandbox (untrusted scripts, §4.3)500ms CPU (= 25ms/tick)workload-dependentPer-execution 5ms soft / 20ms hard unchanged
Chunk flush I/O (persisting explored masks, §5.3)⊙ ≤ 50 dirty chunks/sEvicting a dirty chunk requires flushing it first — the degradation ladder’s first rung is not free
Snapshot write (§4.4)triggered by committed-sequence distanceCaptured on the Grain thread, written off it

The dominant cost is geometry, not viewers — and this paragraph said the opposite until 2026-08-13 (ADR-177). It read “per-viewer snapshot assembly outweighs geometry by more than an order of magnitude”, which was true of the arithmetic it rested on and is false of the measurements. Both halves are now taken: Segment B is Q-110’s marginal cost against Q-003’s budget, and the fog token bucket admits Q-015a’s allowance at Q-075’s cost. At 50 seated players geometry outweighs per-viewer assembly by roughly nineteen times, in the other direction, and Q-016’s registry entry carries the arithmetic. Half of the reversal was visible on 2026-08-02, when the fog-advance measurement refuted Q-015b’s ⊙3ms — nothing re-read the sentence resting on it, which is the drift P6 names. The two normative requirements above are not weakened by this: D-3 alone saves a third of Segment B’s budget at the reference room, measured. Geometry is cheap relative to a per-player scheme because visibility sets are grouped (§5.3); without that grouping it rises by roughly 10× from an already-dominant base.

Figures marked ⊙ are derived from assumed per-operation costs, not measured. They are published in this form deliberately: a specific, falsifiable claim is more useful than an unfalsifiable stage allocation. The benchmarks have now landed for Table A (ADR-177, 2026-08-13) and every ⊙ above is gone; Q-003 and Q-007 are normative, and the rig that produced them runs weekly rather than once. Table B keeps its two, and the residual is named rather than left: chunk-flush I/O has no rig, and the geometry row’s expected cost needs a fog-budget utilisation from real play, which is why Q-015b still carries a curve rather than a value.

What the round did not settle, stated here because a table this confident invites the wrong conclusion. Every figure above is the work AdvanceTickAsync and its callees perform. Orleans’ own message dispatch is unmeasured — deserialisation, the activation’s scheduler, the turn boundary — and at the seat counts Q-111 admits it is the term most likely to bind. Q-112 is registered as a lower bound for exactly that reason, and closing it needs the load-generation tool §14.8’s T4 tier is for.

  • Overrun Semantics — Degraded Fidelity Mode, not Degraded Tick Mode. The 20Hz snapshot cadence (Q-001) is an architectural constant from which §8.3’s interpolation buffer and §4.3’s script limits are derived; it does not change. What degrades when a stage exceeds its budget is the work inside the tick: geometry cadence, optional subsystems, and mask granularity (§5.3’s ladder). A stage exceeding budget emits TickOverrun tagged with room, stage and duration; two consecutive overruns enter Degraded Fidelity Mode and notify players. Snapshots carry fidelityFlags alongside transformTick and maskTick, so clients continue to size their interpolation buffer from the unchanged snapshot interval and need no adaptation logic at all. Halving the snapshot rate instead would drain every client’s interpolation buffer at exactly the moment the server is already struggling — producing the worst stutter precisely when the room can least afford it. Geometry falling behind is visible as MaskStaleness rather than as a tick overrun, and if 20Hz genuinely cannot be sustained on a host, that is an incident — migrate the room or refuse admission (§4.4) — never a silently halved cadence.

5.3 Backend Spatial Partitioning Layer & The Unified Geometry Core

To prevent O(N^2) bottlenecks during spatial queries, the backend Simulation Layer constructs dedicated spatial data structures:

  • BVH & Spatial Hash Grid: Provides O(1) or O(log N) spatial lookups for Line of Sight (LOS) occlusion, 3D triggers, and navigation queries across thousands of static and dynamic scene objects.
  • One Implementation, Two Hosts (Descent.Geometry, ADR-017): LOS occlusion, FOW mask advancement, and A* / NavMesh pathfinding exist as exactly one implementation: a no_std-friendly Rust crate (Descent.Geometry). The backend hosts it natively via a C ABI inside the geometry worker pool; the frontend compiles the same crate revision to WebAssembly (§9.2). There is deliberately no second C# implementation, no second Rust implementation, and no authoritative WGSL implementation — a capability described three times in three languages cannot be kept in agreement.
  • Determinism Contract: all positional and visibility arithmetic inside the crate uses fixed-point (i32.16) integers, never f32/f64. Server and client results are therefore bit-exact on every platform and vendor given identical inputs — and that qualifier is load-bearing. Bit-exactness is a property of the function, not a guarantee that the two hosts are asked the same question. The platform guarantees identical inputs only for own-entity movement over geometry the acting client has been disclosed; it deliberately does not for visibility, because undisclosed occluders are absent from the client’s data set entirely (§9.2). This is why client LOS/FOW is presentation over the authoritative mask rather than prediction of it (ADR-034), and why §9.6.4’s prediction claim is scoped the way it is.
    • Intermediates are explicitly widened, and “panic-free” is not a licence to wrap. An 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. Wrapping produces results that are bit-identical on both hosts and wrong on both — a long-range sight line reporting an occluder that is not there, with matching hashes, no reconciliation event, no rubber-band, and no telemetry anomaly. All intermediates therefore widen to i64/i128; debug and fuzz builds use checked_* arithmetic and the fuzz overflow count must be zero, not merely low.
  • Parity Enforcement (ADR-056): a shared golden corpus (geometry_parity.json: occluder sets, viewer poses, expected mask hashes, expected paths) runs in CI against (a) the native host, (b) the WASM build, (c) the GPU visual approximation (§9.2, tolerance-checked only), and (d) an independent high-precision oracle. The crate revision hash is exchanged in the connection handshake; a client whose revision differs from the silo’s is denied prediction and served server-driven movement instead.
    • (a) and (b) must be bit-identical; both must also agree with (d). Agreement between two hosts running the same code proves consistency, not correctness — it is structurally incapable of detecting the wrapping failure described above. The oracle implements the same geometric predicates in rational or big-integer arithmetic with no performance requirement, exists only in tests, and is never shipped. This does not contradict ADR-017: what ADR-017 forbids is multiple implementations on the product path, because they must be kept in agreement and will diverge. An oracle’s entire purpose is to detect divergence, and it is scoped to predicates only — no optimisations, no data structures — to keep its maintenance cost bounded.
    • The corpus additionally carries an adversarial case class for ADR-034: viewer poses identical, occluder sets differing only by one undisclosed occluder, asserting that the client build refuses to answer rather than answering differently. cargo-fuzz targets cover numerical degeneracy (long range, near-collinear, extreme aspect ratios) against the oracle, in addition to the existing stability targets.
  • World Size Ceiling & Chunked Sparse FOW (ADR-048): an 8km x 8km world is not represented as one global mask. The world is partitioned into 256m x 256m FOW chunks (256 x 256 cells at 1m granularity, 2 bits/cell = 16KiB per chunk per visibility set). Only chunks containing an observer or a pending reveal are resident; the rest live in chunk storage.
    • One budget, not four independent ceilings. Resident cost is chunks × visibility sets × 16KiB, so quoting “1,024 resident chunks” and “64 distinct visibility sets” and “64MB resident memory” as three simultaneous hard limits is arithmetically incoherent — their maxima multiply to 1GiB, sixteen times the memory ceiling. The single normative constraint is therefore resident visibility memory per room activation, with the trade-off published as a curve (chunks_max(sets) = budget / (sets × 16KiB)) plus a table of reference points rather than as numbers that look independent and are not.

    • Visibility sets are allocated per group, and this was the missing piece. A set belongs to a party or faction plus one for the Keeper — not to a player. A player receives an individual set only when their vision genuinely differs from their group’s (darkvision, scrying, seeing the invisible), and such a set is expressed as a sparse delta over the group mask, not a full one. The arithmetic changes from S full masks to G full masks plus I sparse deltas: the 50-player reference case (four parties plus Keeper, ~16 resident chunks) costs about 1.3MiB of full masks, two orders of magnitude inside budget. Without this policy the same room would need 50 sets and hit the ceiling immediately — which is why the ceiling appeared too small when the real defect was an unstated default of one set per player. Grouping is also why geometry is cheap in §5.2 Table B.

    • A budget breach degrades; it does not refuse. The ladder is ordered and each rung is user-visible: (1) evict observerless chunks to chunk storage — noting that a dirty chunk must be flushed first (§5.3 persistence below), so this rung costs I/O; (2) collapse individual delta sets into their group set, losing per-player nuance; (3) coarsen distant chunks from 1m to 2m granularity, a 4× saving, near range unchanged; (4) only then refuse, and the refusal names which lever is exhausted.

    • The governing safety property, and it is one property rather than three rules (ADR-073):

      No rung of the ladder may make any viewer’s disclosure set a superset of that viewer’s disclosure set at full fidelity.

      This replaces the narrower formulation it grew out of — mask granularity may vary only with distance and capacity, never with the presence of any entity. That sentence was correct and covered one dimension of a hazard with three, and patching one dimension would have left the other two equally invisible. Stated as a property, it also becomes directly assertable by the generative test in §14.7 and applies automatically to any rung added later.

      Three concrete rules follow, and the second and third are not obvious:

      1. Collapse discards the delta; it never merges it. The holder of an individual set falls back to the group mask. Nobody else’s view changes at all — merging would hand the whole party the sight of the one member who had it, disclosure triggered by server memory pressure and therefore inducible by anyone who understands the mechanism.
      2. Subtractive deltas are ineligible for collapse. A sparse delta is not necessarily a superset of the group mask: blindness, a blinding spell, or any sense-reducing condition is subtractive, so collapsing it into the group mask shows that player what they should not see — rule 1’s safe direction inverts. Rung 2 is therefore available only for purely additive deltas (darkvision, scrying, seeing the invisible); a room left with only subtractive deltas skips rung 2 and proceeds to rung 3.
      3. Coarsening resolves by AND, not OR. Four 1m cells become one 2m cell, and that cell is visible only if all four were. Resolving by OR would disclose a 1m sliver at every cover edge — precisely when the room is busiest. AND is the conservative direction: coarsening can only ever show less.

      Rung 2 is announced to the affected player, not only to the Keeper. Rule 1’s consequence is a player losing darkvision mid-combat — a rules-level change happening to them — and a notice that reaches only the Keeper leaves that player with an unexplained loss of ability. It is phrased in game language (“your darkvision is temporarily unavailable”), not system language.

      Accepted cost, stated because it changes a capacity estimate: rule 2 materially reduces what rung 2 reclaims in exactly the campaigns most likely to need it — a Call of Cthulhu table using blindness and fear conditions accumulates subtractive deltas, and pressure passes straight to rung 3. The earlier reclamation estimate counted those deltas, and they were never eligible.

    • Authoring-driven ceilings still refuse outright, because a GM can act on them: dynamic occluder count and world extent are properties of the map. The earlier formulation’s real error was classing a player-count-driven quantity with these and then offering “an actionable authoring error” for a condition no amount of authoring can change.

    • Visible is derived and never persisted. Explored is not (ADR-035). “Visible” is recomputed every tick. “Explored” is a function of the room’s entire reveal history, so recomputing it costs events since campaign start rather than events since last snapshot — tens of seconds on every activation of a year-old campaign, in the wake state §9.5 Guardrail 6 labels “seconds”. Explored masks are therefore persisted T0 chunk state keyed (RoomId, BranchId, ChunkId), written in the same transaction as the snapshot that references them, and copy-on-write across timeline forks. Recomputation from reveal events is retained as the CI-verified rebuild and audit path — it is the proof that T1 remains sufficient, not a runtime mechanism. BranchId in that key is not decoration: without it, forking to before the party explored the east wing leaves the east wing permanently revealed on the new timeline.

5.4 Simulation Subsystems

  1. Line of Sight (LOS): Computes intersections between actor vision cones and 3D meshes / 2D wall structures in real-time, generating visibility masks. Executed by Descent.Geometry (§5.3) and authoritative: client-side vision is a presentation of this result and never a substitute for it (ADR-034). The word matters — describing it as a prediction is what previously licensed a client-side implementation whose disagreement with the server was itself a disclosure channel. Visibility is a security property (ADR-017).
  2. Fog of War (FOW): Updates the resident chunk masks (§5.3) based on LOS, maintaining “Unexplored / Explored / Visible” state phases per visibility group (§5.3). Grouping has a rules-level consequence that belongs here and not only in the memory discussion: members of a group share their explored map, which matches how a party moves together, while a member whose vision genuinely differs carries a sparse delta over the group’s. Players who split up therefore accumulate individual deltas, and if too many accumulate, §5.3’s ladder collapses them back into the group mask with a Keeper-visible notice.
  3. Navigation & Pathfinding: Computes grid-based and NavMesh 3D pathing for token movement. The server issues the complete authoritative path to the client; the client animates the issued path rather than deriving a competing one (§9.6.4).
  4. Triggers & Spatial Events: Monitors Actor coordinates entering ZoneBounds and dispatches spatial events (traps, door toggles, audio triggers). Along a lease path, triggers are evaluated against the server-issued authoritative path (item 3), cell by cell — never only at the drop point. During a peer-driven drag the server receives no authoritative positions (§7.1), so drop-point-only evaluation lets a player drag a token the length of a trapped corridor and trigger only the trap they land on. The advisory position stream of §5.1.1 rule 7 is explicitly not eligible for this: it is non-durable and filtering-only, and making a non-durable value load-bearing for game events would put trap resolution outside the event log. This is the second, independent reason the server issues complete paths.
  5. Ambient Audio Zones: Computes 3D spatial attenuation for positional audio based on actor positions.
  6. Dynamic Lighting State: Evaluates light source states, color gradients, and intensity flickering.

6. Asset Pipeline, Workshop UGC & Sandbox

6.1 Asset Bundle System & Ultra-Compression

Manages asset packages for official distribution and community workshop extensions.

  • KTX2 / Basis Universal Ultra-Compression: All 3D mesh textures and 2D tactical maps mandate KTX2 / Basis Universal super-compression formats, drastically reducing GPU VRAM consumption by up to 70% across all device profiles.
    • Transcode Is a Budgeted CPU Step: Basis Universal is a supercompressed intermediate, not a GPU-native format. Every texture must be transcoded on the CPU (WASM) into the target family (BC7 / ASTC / ETC) before upload, and zstd-supercompressed payloads must additionally be inflated. This work is real, is tens of milliseconds for a 4K texture, and is explicitly budgeted in the streaming pipeline (§9.6.1) rather than described as “piping bytes straight into VRAM”.
  • Cross-Origin Isolation & the Edge Fetch Service (COOP/COEP Defense): To enable SAB on the frontend, browsers mandate strict Cross-Origin Embedder Policies (COEP). Under COEP: require-corp a subresource lacking CORP is blocked outright, so a player pasting an external image URL genuinely cannot be served client-side; the frontend therefore prohibits direct loading of external resources and the fetch happens server-side. That was the right conclusion, but it makes the fetcher a trust boundary, not transport plumbing — which is why it is named a service rather than a proxy (ADR-039).
    • This is a player-driven arbitrary-URL server-side fetcher, and it is trust tier P4 (§6.4). Left inside the application’s network, it is a textbook SSRF path: §10.1.1 places Garnet on an internal VNet IP and states it is “completely isolated from the public internet”, and a player pasting that IP — or a cloud metadata endpoint — as a “map image” would have the platform fetch it, store the response in R2, and hand back a download link. It also bypasses every quota in §6.2, because those are attached to presigned direct-upload issuance and this path does not use them.
    • Isolation of the service itself: it runs outside the application VNet with no managed identity, no workload identity federation, and no route to Garnet, Neon, or the silos — its only credential is R2 write access scoped to a single quarantine prefix.
    • Destination admission on resolved addresses, re-checked after every redirect: the hostname is resolved and admitted only if every resolved address is public unicast (denying RFC1918, 127/8, 169.254/16, ::1, fc00::/7, fe80::/10, and IPv4-mapped forms), the connection is made to the address that was checked rather than re-resolving at connect time — otherwise DNS rebinding wins the race — with at most two redirects and no non-HTTP(S) schemes. Blocklisting hostnames instead is defeated by IP literals and by DNS records pointing inward; admission must be on addresses.
    • Byte and rate ceilings: Content-Length is enforced when present and a hard cap is applied to the streamed body, because an absent or dishonest length must not be load-bearing. Per-account token buckets cover fetches/hour, bytes/day, and concurrency.
    • Decoding happens elsewhere, under quarantine. Fetched bytes are never decoded in the real-time host — an image bomb would OOM a silo and take every co-located room with it, which is precisely the “Asset Baking Poison” §6.2 exists to prevent, re-entering by another door. The service performs byte-level validation and writes to a quarantine prefix; the existing §6.2 baking pool decodes and re-encodes under memory and decoded-pixel-count limits (a decompression bomb has a small file size).
    • The response is never returned verbatim — output is always a re-encoded asset on our own origin. This removes the fetch-and-read-back primitive even if admission control has a gap: reaching an internal endpoint yields, at best, a failed image decode. Failures are reported to the user with a single generic reason, because differentiated errors (timeout vs refused vs 403) are an oracle for internal network topology.
    • Accepted costs: a separate deployment unit with its own small always-on cost (§10.2); no VNet route means its own scoped credentials and an external telemetry path; and pasting an image becomes an asynchronous import with progress, not an inline paste, because fetch → quarantine → decode → re-encode → R2 → CDN is not an interactive-latency operation.
    • Invoking it is a GM authority, not a player one (ADR-117, superseding ADR-039). Every control above is retained unchanged — the mechanism was never the weak part. What changed is the population that can reach it: §6.4’s P4 row read “any player”, and a URL-fetch primitive exposed to the widest possible principal set is the one variable this design could reduce without giving anything up, since map images are authored by the person building the map. Import is therefore gated by the same §9.2 keeper authority that governs occluder authoring, checked at the authority tier rather than in the transport for the reason recorded there: a transport check is bypassed by the next caller.
      • Full removal was considered and rejected, and the reasoning is worth keeping because it will be re-proposed. Deleting the fetcher does not delete the trust tier — P4 classifies by what enters, so uploaded bytes stay P4 with the image-bomb and decoded-pixel-count defences of §6.2 unchanged. It also consolidates every player-supplied asset onto the upload path, whose chokepoint is weaker: §6.2’s quota sits at presigned-URL issuance because “storage cost is incurred before any RoomGrain sees the file”, so face 5 of §10.3 folds into face 1 rather than disappearing. And on C-Companion (§9.7) “download it locally, then upload it” is several steps through a mobile file picker for a flow that is one paste today.
      • Bandwidth was not the argument. §10.2 records asset egress at $0 (Cloudflare R2); the saving from removal would have been one small always-on deployment unit, not a bandwidth line. Stated because “it saves egress” is the intuitive justification and it is not true here.
  • Isolation Route Partitioning (the cost COEP actually imposes): cross-origin isolation is a page-wide, all-or-nothing property, and player-pasted images are the least of it. COEP: require-corp blocks any subresource or iframe lacking CORP (CDN fonts, Monaco assets, analytics, support widgets, payment iframes), and COOP: same-origin severs window.opener, breaking third-party OAuth and hosted-checkout popups. The platform therefore partitions by route: the game client route is isolated and self-hosts 100% of its subresources (no third-party origin, enforced by a CI check that fails the build on any external host in the bundle graph or CSP); billing, authentication, and marketing routes are non-isolated and live on separate paths/subdomains. Where an embed is unavoidable inside the isolated route, COEP: credentialless is used deliberately and documented. Rationale: a single marketing tag added to the game route would silently strip isolation and disable SAB. The blast radius of that is now one degradation, not four (ADR-052). Once client visibility became presentation-only (ADR-034), the shared WASM arena lost its reason to exist and was retired, so cross-origin isolation no longer gates the geometry path at all; its only remaining consumers are two Render-Worker-to-Main-Thread channels (§9.1.2, §9.1.3), whose absence relaxes the frame-skew budget by one frame. No capability and no correctness property depends on it. The route partitioning and the CI check are kept anyway — SAB is still worth having, and a build-graph check is cheap — but the earlier framing overstated the stakes, and overstated stakes are how a control gets removed later by someone who has correctly noticed the justification does not hold.
  • Streaming Assets Through OPFS Instead of the JS Heap: the architecture abandons the traditional whole-asset ArrayBuffer loading pattern. All massive KTX2 textures and 3D model files are written directly to the local disk’s OPFS (Origin Private File System) via fetch() WritableStream; the streaming worker uses FileSystemSyncAccessHandle for synchronous byte access and hands GPU-ready payloads onward. This keeps the JS heap footprint small and supports 8km x 8km campaigns.
    • The motivation stated honestly. This was previously justified as breaking “the hard 2GB memory limit (Heap Limit) of the V8 JavaScript engine”. That framing is imprecise: in modern V8 an ArrayBuffer’s backing store is allocated outside the old-space heap and does not count against the heap limit. The real constraints are total renderer-process memory, the maximum length of a single ArrayBuffer, and — decisively for a large map — VRAM. The conclusion is unchanged; the reasoning matters because it determines what else must be bounded.
    • OPFS does nothing for VRAM, so VRAM needs its own budget. §9.6.2 correctly notes that 8km maps exceed typical GPU memory, but a per-profile resident VRAM budget in bytes is required in the same normative form §5.3 uses for visibility masks — exceeding it drops LOD rather than failing an allocation. Without such a budget, “we stream from disk” is quietly assumed to have solved a GPU-side problem it never touched, and the symptom is intermittent device loss on large maps (§9.5 Guardrail 1), which is among the hardest failures to attribute correctly.
  • Chunked Authenticated Encryption Streaming (Content Protection): To protect premium third-party IP without sacrificing OPFS performance, the system uses chunked authenticated encryption. The backend Azure Worker splits large files into fixed-size chunks (512KB, sized to the frame budget in §9.6.1 rather than to convenience) and encrypts each independently with AES-GCM 256. As the player pans, the streaming worker implements Spatial Streaming, fetching only visible chunks from OPFS, decrypting via WebCrypto, verifying the Authentication Tag, transcoding, and uploading to the GPU.
    • Nonce Construction (mandatory): the nonce is HKDF(bundleKey, contentHash) derived per file version, concatenated with the chunk index, and the per-version salt is stored alongside the ciphertext. Deriving the nonce from the chunk index alone would reuse (key, nonce) across two versions of the same asset — and AES-GCM nonce reuse is catastrophic, not merely weak: XOR of plaintexts is recoverable against known KTX2 headers, and the authentication subkey becomes solvable, letting an attacker forge chunks that pass tag verification. Keys are additionally derived per bundle version, so no two published artefacts ever share a keystream.
    • Honest Threat Model: this mechanism raises the cost of casual redistribution. It does not and cannot prevent a determined extraction: the key must reach WebCrypto and the plaintext must reach writeTexture, so a modified client or a hooked crypto.subtle recovers the asset. The only content that is genuinely protected is content that never leaves the server — which is precisely why proprietary rule logic executes backend-side (§12.2.6). Marketing and contractual language must reflect this rather than claiming a guarantee.
    • Keys Are Session-Scoped and Never Persisted (ADR-060). Piracy and revocation are different problems, and the paragraph above addresses only the first. §7.2 requires that a lapsed licence or a takedown have a real deletion path for text corpora; assets had none — ciphertext sits in OPFS until the 30-day LRU sweep (§9.6.2), and if the key were cached alongside it, a publisher terminating a licence could be told only that the bundle had been delisted while every client that ever loaded it kept playing indefinitely. Therefore: OPFS stores ciphertext only, bundle keys are exchanged per session against a short-lived token, and revocation is implemented by ceasing to issue keys. Remote-wiping OPFS was rejected as a remedy because it depends on the client cooperating in its own enforcement and does nothing for a device that is offline.
    • The consequence for offline replay must be stated, not left implied. Because keys are not persisted, protected bundles are unavailable in network-detached replay (§7.3.2) and render as labelled placeholder geometry; replay pins only unprotected baked assets, under an LRU exemption bound to the replay file’s lifetime. “Zero server cost, network-detached cinematic replay” is therefore partially available for campaigns containing protected content — which is a real reduction of a selling point, and the honest version of it.
{
"bundleId": "bundle_victorian_mansion_pack",
"name": "Victorian Mansion Asset Pack",
"version": "1.2.0",
"dependencies": [ "bundle_core_base_assets" ],
"assets": [
{
"assetId": "asset_victorian_wall_01",
"meshUri": "meshes/wall_01.glb",
"textures": {
"albedo": "textures/wall_albedo.ktx2",
"normal": "textures/wall_normal.ktx2"
},
"lods": [
{ "level": 0, "meshUri": "meshes/wall_01_lod0.glb", "distance": 10.0 },
{ "level": 1, "meshUri": "meshes/wall_01_lod1.glb", "distance": 30.0 }
],
"collisionBounds": { "type": "box", "size": [2.0, 3.0, 0.5] }
}
]
}

6.2 Asset Baking Pipeline & Compute Separation

To prevent highly CPU/RAM intensive 3D geometry processing from “poisoning” the real-time SignalR host, asset baking is strictly isolated into an independent, asynchronous background worker cluster (Asset Baking Workers). This cluster utilizes Azure Container Apps Jobs (Event-Driven) paired with Azure Service Bus:

  1. Trigger Mechanism: The frontend uploads raw files directly to Cloudflare R2, and the main server publishes an AssetBakeTask to the Service Bus.
    • Quota Is Enforced at URL Issuance, Not at Bake Time: because the client uploads directly, storage cost is incurred before any RoomGrain sees the file — a RoomGrain token bucket cannot defend it. Every presigned URL is therefore signed with a content-length-range ceiling, is single-use and short-lived, and is issued only after checking the requester’s per-account storage quota, per-hour URL budget, and count of already-pending un-baked uploads. Unclaimed or oversized objects are reaped by an R2 lifecycle rule.
      • The quota that check names is Q-085 (ADR-119), and until 2026-08-08 it had no value anywhere — a specified gate with no number behind it, which is the shape ADR-045 exists to catch. It bounds stored bytes in R2, not uploaded bytes: the two diverge under re-upload and versioning, and it is the stored copy that is billed.
      • It is a cost bound and never an abuse bound (ADR-105). It protects the bill, so it scales with entitlement tier (Q-086, Q-087, ADR-121); it is not a defence against a hostile account, and nothing here should be cited as one.
      • Stored bytes and read volume are separate quantities and must not be conflated. A storage quota bounds what is held; R2 Class B operations are reads, and an account well inside its quota can generate more of them than one at the ceiling. Class B volume has no bound registered here and this sentence is not one — it is a statement that the gap exists.
    • Download issuance has an authority, and until ADR-123 this document specified only the upload half. ADR-006 named the presigned pipeline for writes; how a custom asset is read back was left to inference, which for a private-by-default vault is the more consequential half. Therefore:
      • Custom uploads live in private R2 buckets with no public access and no index, and are reachable only through a short-lived presigned read URL (Q-089) minted per request.
      • Issuance re-derives authority from the RoomGrain per request — the asset must belong to a room the caller currently holds a membership grant in (ADR-104), and a revoked grant stops issuance immediately. It is not enough that a URL was issued while the caller was a member; that is the point of minting per request rather than per session.
      • A room that is not Active does not issue. Dormant and Deactivated rooms (ADR-072) mint nothing, because an asset reachable while nothing is running is an asset reachable by a link alone.
      • A presigned URL is a bearer capability and is handled as one. It is returned in a response body — never in a redirect Location, where a referrer leaks it — is never written to a log or a trace attribute, and carries the shortest TTL that completes a fetch. The Marketplace corpus states the same rule for the same reason (§8.2 there); it is repeated here because the two systems mint URLs independently and a rule stated in one repository is a rule the other does not have.
      • What this does NOT claim: custom uploads are not encrypted at rest. ADR-060’s chunked AES-GCM protects premium third-party bundles; a creator’s own map is stored as ordinary bytes, and a privacy guarantee implying otherwise would be the overclaim §6.1 already warns about for content protection.
  2. Scale-to-Zero Compute: ACA uses KEDA to monitor queue depth, instantly spinning up ephemeral .NET 10 Workers (packaging native C++ binaries like toktx) to process the assets.
  3. FinOps Destruction: Upon completion and upload to R2, the job container destroys itself immediately, terminating billing and achieving ultimate cost control.
graph TD
A[Raw 3D Asset: GLTF/GLB] --> B[Asset Processor]
B --> C[1. Generate Simplified Collision Mesh]
B --> D[2. Generate Occluder Mesh]
B --> E[3. Bake NavMesh Node Data]
B --> F[4. KTX2 / Basis Universal Compression]
B --> H[5. Bake Top-Down 2D Tile<br>Profile C requirement, ADR-005]
H --> G
C --> G[Optimized Asset Package<br>Ready for Streaming]
D --> G
E --> G
F --> G

6.3 Kitbashing Library & Assisted 2D-to-3D Extrusion

  • Modular Kitbashing: Pre-built modular 3D blocks (dungeons, Victorian manors, sci-fi bases) allow GMs to construct 3D maps without 3D modeling expertise.
  • Assisted 2D-to-3D Extrusion: Assists GMs in analyzing uploaded 2D PNG/WebP tactical maps, extracting wall contours and collision bounds to produce functional 3D extruded geometry with LOS collision.
    • Extraction Output Is Reviewable, Not Authoritative-on-Arrival: the extruded geometry becomes the input to authoritative server LOS (§5.4), so a bad extraction silently creates invisible walls — a hatched or shaded hand-drawn map can yield a wall per hatch line, blocking movement and cutting sight lines in open floor with no visual cue and no repair path. The pipeline therefore (1) presents the extraction as an editable overlay on the source image with add/remove/merge tools, (2) reports confidence metrics and flags anomalies (segment count, wall-area ratio, unclosed contours) with a re-run option, and (3) requires explicit GM confirmation before the geometry is promoted to LOS-authoritative.
    • The safe default is inverted from what it was. Unconfirmed maps previously ran “in decorative mode with no collision” — but in this architecture a wall is simultaneously a collision volume and an LOS occluder (§5.4.1), so “no collision” means “no occluders”, which means the whole map is revealed to every player within vision radius. All three protections above are aimed at the opposite failure (over-blocking: invisible walls in open floor), and that failure is recoverable while this one is not — once the layout is in the players’ heads, confirming the geometry afterwards changes nothing. An unconfirmed map is therefore fully Unexplored to non-GM viewers and refuses token placement until the GM confirms. The GM sees the editable overlay; nobody else sees anything. Failing closed converts an irreversible disclosure into a recoverable obstruction.

6.4 Zero-Trust Declarative UI Schema Engine & UGC Workshop

To eliminate DOM-based XSS/RCE vulnerabilities and guarantee immunity from future framework version migrations, Descent VTT renders all User-Generated Content (UGC) workshop modules and community character sheets through a first-party JSON AST rendering host built on Lit / Web Components. The single normative rule, consistent with ADR-010 and §9.5 Guardrail 5, is: third parties ship data, never code that reaches the main thread.

  • JSON AST Is The Only Delivery Format (ADR-010): Third-party rule creators author custom sheets and UI panels as JSON AST UI Descriptors validated against a published JSON Schema. The core SolidJS application (or the first-party Lit host below) interprets that descriptor and instantiates first-party components. A creator never publishes a JavaScript class, a custom element definition, a template literal, or any artifact that executes in the main thread’s realm. Creator logic executes exclusively inside the QuickJS/WASM PluginWorker (§9.5 Guardrail 5).
  • Lit / Shadow DOM Is a Styling Boundary, Not a Security Boundary: The first-party Lit host uses Shadow DOM encapsulation to guarantee zero CSS leakage into the main SolidJS Tailwind design system, and to keep third-party visual layouts from disturbing platform chrome. It is explicitly not relied upon for isolation: Shadow DOM does not restrict document, fetch, localStorage, window.top, or cookie access, so any third-party code running inside a shadow root would be fully privileged. Because §6.4 delivers no third-party code, this is moot by construction — which is the point.
  • Extension Trust Tiers: the table enumerates along two axes — P0–P3 by what executes, P4 by what enters. Adding a row requires an ADR (ADR-045). Neither axis is authority, and this table classifies extensions rather than principals — stated so a reader stops expecting an answer it does not contain. The GM appears in it once, in P4, as a source of URLs; where principal authority is modelled is §8.2’s Visibility Channels. The practical consequence is bounded by ADR-086: a plugin’s interest set may never exceed the disclosure set of the principal who admitted it, evaluated against those channels rather than through a second permission model that would then have to be kept consistent with them. A GM-installed plugin observes what the GM is entitled to observe — a great deal — and never more. Q-027 (PLUGIN_INTEREST_SET_MAX) accordingly becomes a resource bound rather than a disclosure bound; it was one pending value doing two jobs and only one of them was urgent.
TierMechanismIsolationWho may publish
P0 — Privileged.NET cartridge loaded via AssemblyLoadContext (§4.4, §12.2.6)None (full host privileges). ALC provides versioning/unload only.First-party and signed, code-reviewed partners only. Requires signature verification at load.
P1 — Sandboxed Server LogicJint macros via Descent.Sandbox (§4.3)Deep-frozen intrinsics, pure-read seams, bounded Effect buffer with recorded premises, tick-relative CPU/heap budgets.Any creator.
P2 — Sandboxed Client LogicA wasm32 guest on the silo’s three-name ABI, in a PluginWorker (§9.5 Guardrail 5; ADR-144, ADR-146)No DOM, no window, no fetch; zero imports, so the guest cannot address anything outside its own linear memory; one runtime worker per plugin, a fresh instance per invocation, under a per-frame aggregate ceiling, a token bucket, a heap ceiling and a hard ceiling enforced by Worker.terminate(); read state by copy; mutations only via the CQRS Command Queue.Any creator.
P3 — Data OnlyJSON AST UI descriptors, asset bundles, declarative schemas (§6.1, §6.4)Schema-validated data; no execution.Any creator.
P4 — External / Player-Supplied InputURIs named by a GM at run time and fetched server-side (map images, avatars, audio) — §6.1 Edge Fetch Service, re-parented to ADR-117Identity-less, VNet-isolated fetcher; resolved-address admission re-checked per redirect; byte, time and rate ceilings; decode quarantined in the baking pool; response never returned verbatim.GM only since ADR-117. This row previously read “any player”, which is why it carries the strongest network isolation of any tier — the isolation is unchanged, the population that can reach it is not. Player-supplied bytes still enter by upload (§6.2), and that is still this tier: P4 classifies by what enters, not by how it arrived.
  • Why P4 was missing, and why that is the more useful lesson. The previous table asserted it enumerated every extension mechanism and closed with “there is no fourth row” — a sentence doing genuine rhetorical work, which is why replacing it needed a decision rather than an edit. What it missed was a whole category: the table classified mechanisms by what executes, and “a location a player names, fetched by us” executes nothing. P3 covers data a creator publishes and we validate; P4 covers data we go and get on a player’s instruction, where the act of fetching is itself the attack surface. Enumerations classify along an axis, and an enumeration is only complete with respect to the axis it chose.
  • The zero-trust inventory is therefore a repeatable procedure, not a finished table. Four boundary classes must be re-walked whenever a feature is added: code entering, data entering, requests we make on someone else’s instruction, and data leaving. The third class is the one that produced P4; the fourth is the one that produced §9.6.3’s payload content rule. A release checklist item asks whether this release added any of the four.
  • P1 and P2 are peers in who may publish and were not peers in what is bounded. P1 had a full resource regime (§4.3) while P2’s isolation column listed only access controls — no CPU limit, no heap limit, no preemption, and all plugins sharing one runtime. A single plugin looping over 500 tokens could therefore starve every other plugin and the JSON-AST bridge, freezing the Keeper’s combat tracker, with nothing identifying the culprit. Access control and resource control are different properties; a tier table that shows one and omits the other misrepresents the tiers as equivalent. §9.5 Guardrail 5 now carries the matching regime.
  • Dual-Track Logic Editors (SolidJS + Rete.js): Complemented by a visual node editor for event logic, a Monaco Editor for backend-sandboxed macros, and S3 Direct Uploads via backend Presigned URLs for heavy asset bundles. The node editor discards React ecosystems in favor of the framework-agnostic Rete.js (v2) for topology, with DOM rendering driven entirely by SolidJS fine-grained Signals. Dragging nodes only mutates matrix properties (style.transform), completely eliminating Virtual DOM layout thrashing.

7. Database, Persistence & Knowledge AI Layer (CQRS / Event Sourcing)

Utilizes PostgreSQL coupled with Marten to establish a robust Event Sourcing and CQRS architecture, leveraging native JSONB for event payloads and asynchronous read-model projections.

7.1 Event Sourcing, CQRS & High-Frequency Mitigation

  • Event Store (Marten) & State Snapshots: The database no longer stores mutable “current states”. Instead, domain events are appended to an event stream. To prevent the “Wake-up Storm” where a RoomGrain must replay tens of thousands of events when the server boots from scale-to-zero, the system enforces periodic state snapshots. Upon cold boot the Grain reads the snapshot in O(1) time, reducing replay latency from seconds to milliseconds, while still guaranteeing infinite “Undo/Redo” and perfect campaign auditability.
    • Two different artefacts, two different owners — do not conflate them. Marten’s projections produce T2 JSONB read models (async daemon, owned by the worker of ADR-043, non-authoritative). The T0 snapshot is a different thing entirely: captured from RoomGrain memory, keyed and validated by sequence, and used to seed an activation. Describing them in one breath is what invites an implementation using a Marten inline projection as the activation seed — which, by this document’s own taxonomy, is a T2 artefact and is forbidden by §2.1.1’s fourth prohibition.
    • Snapshot contents are bounded. Every snapshot records its BranchId, SourceEventSeq, the Descent.Geometry revision, and the schema version it was written at, and excludes derived data that is genuinely cheap to rebuild — BVH and Visible masks. Explored FOW masks are no longer in that list: they are persisted as separate branch-keyed chunk rows written in the same transaction as the snapshot (§5.3, ADR-035), because “recomputable” is not the same as “cheap” when the recomputation is bounded by events-since-campaign-start rather than events-since-last-snapshot.
    • Ordering and hand-off are normative (ADR-037, §4.4). A snapshot’s SourceEventSeq may never exceed the highest committed sequence; the Grain captures on its own thread and a writer persists after the commit confirms, so the mailbox never awaits I/O. A snapshot is a T0 accelerator, not a T2 read model (§2.1.1).
    • The persisted format is MemoryPack, and that is stated here rather than left to be inferred (ADR-076). It was previously derivable only from the intersection of two paragraphs about other things — §10.1 #3 says every cache payload uses MemoryPack, and ADR-058 requires the cached snapshot to be byte-identical to the T1 payload — so a requirement on the persistence layer existed only in the overlap of a cache paragraph and a serialization paragraph. It is recorded on the persistence layer so that anyone changing the cache format can see that it reaches T1.
    • Snapshots are never upcast; a SnapshotSchemaVersion mismatch discards and replays. This is deliberately the opposite of the event rule, and the asymmetry is the point: an event is evidence and must stay readable forever (§7.5.1 rule 1), while a snapshot is an accelerator that §2.1.1’s invariant guarantees is reconstructible from T1. Maintaining an upcast chain for a discardable artefact applies the cost structure of events to something that does not need it — and MemoryPack’s strict binary schema makes such a chain fragile in a way the JSON-based event path is not.
    • A schema change is therefore a declared migration, not a discovered replay storm. Discard-and-replay is safe but it is a cliff: a mechanism introduced to prevent the Wake-up Storm would otherwise cause a platform-wide one at every version bump. So a snapshot schema change is migration-visible in the same sense as the partition count (Q-020): the deploy declares it, and the ADR-043 worker pre-generates new-version snapshots shard-scoped and rate-limited before the version goes live, reusing its existing shard leases rather than adding infrastructure. Rooms not pre-generated fall back to replay — correct, merely slower — and the number of rooms that will pay it is known beforehand instead of being a surprise.
    • Cache keys include SnapshotSchemaVersion. The key already carries (BranchId, SourceEventSeq). Without the schema version an old-format blob is served byte-identically and correctly, exactly as ADR-058 guarantees, and then fails to deserialize at the consumer — a failure produced by the intersection of two individually correct mechanisms, and among the hardest classes to attribute because neither component is misbehaving.
  • Event Upcasting & Versioning (ADR-020): To guarantee 10-year operational stability, when game rules or data structures undergo breaking changes (e.g., adding an attribute to DamageEvent), the system strictly enforces Event Upcasting at the Marten layer. Legacy JSON payloads are upcasted to the latest version prior to deserialization. Three constraints make this survivable over a decade of third-party cartridges:
    1. Upcasters are declarative data transforms, registered by (cartridgeId, eventType, fromVersion) — not code paths inside the authoring assembly. Deserializing a 3-year-old event must never require loading the 3-year-old DLL, because that DLL may no longer be loadable on the current SDK major (§3.1).
    2. Retired event types get a terminal upcaster to an opaque RetiredEvent{originalType, payload} envelope. Removing a rule from a cartridge must not brick every room that ever used it. An event that still cannot be resolved yields Archive Mode (§3.1), never a failed activation.
    3. Chains are collapsed at snapshot time. Because snapshots are written at the current schema version, the upcast chain applied on any hot path is bounded by “events since the last snapshot”, not by “events since 2026”.
    • These three constraints are correct and insufficient, and §7.5 works the case that shows why. All three are about making an old payload deserialize. None of them addresses what the payload means once the rules changed, what happens to an attribute’s registry definition after it is removed, or what a timeline checkout across that boundary is supposed to produce. Those are separate mechanisms (ADR-069, ADR-070, ADR-071), and the gap is invisible until someone removes an attribute or time-travels — at which point it is a live campaign, not a design review.
  • High-Frequency Mitigation (The Bounded Buffer): To prevent database DDOS from high-frequency actions (e.g., dragging tokens at 60Hz), the architecture strictly bifurcates state synchronization:
    1. Ephemeral State (0 DB Writes): Rapid cursor movements, real-time ruler drawing, and mid-drag token positions bypass the DB entirely. Leveraging the platform’s dedicated LiveKit SFU infrastructure, these are broadcasted exclusively via WebRTC DataChannels. This ensures sub-50ms latency while guaranteeing 0 requests hit the ASP.NET Core backend or Garnet cache, achieving true physical backend isolation. Ephemeral transmission requires a held Ephemeral Ownership Lease (§5.1.1); for the lease’s duration the server suppresses that entity’s transform from the fixed-tick snapshot, so the ephemeral and authoritative paths never describe the same field simultaneously.
    2. Authoritative Domain Events (Batched DB Writes): Only finalized actions (e.g., dropping the token, confirming damage) generate events. These are pushed into an in-memory System.Threading.Channels queue and flushed to Marten in micro-batches (e.g., every 50ms), ensuring maximum PostgreSQL write throughput without lock contention. The batch is a durability window, not a correctness hole: a client is never told an action is final before its DurableSeq commits (§4.4), and any state it optimistically confirmed beyond the last durable sequence is force-corrected on reconnect.
  • Projection Ownership (ADR-043, superseding ADR-021): Asynchronous projections require an owner that exists independently of HTTP traffic. The Marten async daemon, Yjs flattening jobs, room lifecycle/archival sweeps, and the LRU/quota sweeps therefore run in a dedicated Descent.Vtt.Worker deployment. Two failure modes are excluded by construction: with zero instances (pure scale-to-zero) the last events of a session are never projected, so search, sheets, and any T2 consumer silently lag — and a stale sheet edited by a player overwrites content that had not yet been flattened; with N unleased instances, multiple daemons contend for the same progression rows.
    • minReplicas: 2, not 1. Single-writer leadership is only worth its complexity if there is a standby to take over; at one replica the platform pays for leader election and gets no failover, so a routine ACA node move stops all projections platform-wide and the “zero instances” failure above applies for the duration. Two replicas — one active, one warm — is an acknowledged always-on cost (§10.2) and the smaller of the two costs on offer.
    • Work is sharded into independently-leased units, keyed hash(RoomId) and aligned to the event partition count of §7.4 so a shard’s rooms sit in one partition. Replicas take shards up to a per-replica cap. This is what makes a rebuild isolatable today and horizontal scaling possible later without changing the model.
    • A rebuild is shard-scoped, rate-limited, and pausable — and it blocks stale writes rather than warning about them. §10.4’s rollback path for a bad projection is to rebuild side-by-side and swap; executed by a single global writer, that rebuild competes with every live room’s projection and produces hours of platform-wide T2 lag — which triggers precisely the stale-sheet overwrite this ADR exists to prevent. So a rebuild leases only the shards it touches, can be paused at 03:00, and any API accepting an edit derived from a T2 payload behind the rebuild watermark rejects it with an explicit reason. A surfaced SourceEventSeq lets a consumer detect lag; it does not stop a player saving over unflattened content.
  • Attribute Schema Registry: Ruleset cartridges register dynamic property metadata via IAttributeDefinition, enforcing type bounds (e.g., min/max ranges) for automated UI generation:
public record AttributeDefinition(
string Key,
AttributeType Type,
double MinValue,
double MaxValue,
string DisplayName
);
  • Spatial Data Management (PostGIS): Integrates Npgsql.NetTopologySuite for global spatial queries and region management.

7.2 Knowledge & AI Layer

Decouples AI reasoning from storage via the ILLMProvider interface and a Retrieval-Augmented Generation (RAG) pipeline:

graph TD
A[PostgreSQL + pgvector<br>Rule Embeddings] -- Vector Similarity Retrieval --> B[Knowledge Retrieval Service]
B -- Context + Query Prompt --> C[RAG Engine Layer<br>ILLMProvider Abstraction]
C --> D[GeminiProvider]
C --> E[OpenAIProvider]
C --> F[LocalOllamaProvider]
  • ILLMProvider Abstraction: Encapsulates GenerateAsync() and EmbedAsync(), preventing vendor lock-in and supporting local models (Ollama/LM Studio).
    • The two halves have been separated and only one of them is built (ADR-168, ADR-170). (Added 2026-08-12.) EmbedAsync() no longer has a provider at all: embedding happens inside the search engine, on its own CPU, under a Hugging Face model the engine loads — so there is no code path by which a chunk of text reaches an embedding vendor, and none can be configured. GenerateAsync() has a port and one implementation, an offline mock; this platform holds no model key and can reach no provider. The diagram above therefore describes an intended shape rather than a built one, and the three named providers are aspirational — which is stated here because a reader who meets only the diagram would conclude otherwise.
  • GM Authority Principle: AI provides rule references and exact text citations only. AI NEVER forces world state mutations. Final ruling authority belongs 100% to the Game Master (GM/KP).
    • This principle is now enforced structurally rather than by convention (ADR-170). The AI session is an Orleans grain that produces an AiCombatAction proposal: a record with no verb, in a layer that cannot name IRoomGrain, delivered through a sink that holds a single-connection sender and therefore cannot broadcast. The game master reads a proposal and, if they accept it, issues an ordinary command down the path every other command takes — where the aggregate validates it, the event stream records it, and per-viewer assembly decides who learns of it. The second half of that sentence is a disclosure control, not a formality: a proposal names an actor and a hit-point change, and sending one to a room would tell every player that the actor exists and was targeted, including the players for whom §8.2 conceals it.
  • Corpus Provenance & Revocation: “exact text citations” over licensed rulebooks means the platform stores retrievable plaintext and embeddings of third-party copyrighted material — and §7.4 retains both in hot storage indefinitely. Every embedding and text chunk therefore carries sourceId, licenceId, and ingestedAt, retrieval filters on the caller’s entitlement for that licenceId, and a licence-revocation job can purge every chunk and embedding for a sourceId on demand. Without provenance keys, a takedown or lapsed licence has no deletion path and citation scope cannot be limited to what the querying user is entitled to see.
    • Campaign-derived embeddings get the same revocation path, and until ADR-122 they had none. The paragraph above builds a purge job for licensed material keyed on sourceId, and the row below records that campaign summaries and embeddings are Campaign(RoomId, BranchId) scope — but nothing said what happens to them when a campaign is deleted. A licence takedown had a deletion path and a user’s own content did not, which is the wrong way round. Deleting a campaign purges every summary and embedding under its scope key, reusing that same job with a different key rather than building a second one (ADR-122). It is a derived artefact in Q-054’s sense, so its purge is bounded by that deadline rather than by a new quantity.
      • This does not weaken §7.3’s zero-deletion claim, and the qualification already exists there: that claim is scoped to the timeline — no operation a player or GM performs inside the game destroys history. Campaign deletion is an account-level act like §7.6’s erasure, not an in-game one, and it is on the same side of that line.
    • What the platform does with user content, stated as a covenant rather than left to inference (ADR-122). User content is embedded — vectorised — so that in-game AI features can retrieve it, and embedding is not training. The distinction is technical and real, and it is stated here because it is invisible to a reader who meets only the marketing claim: there is no training pipeline in this architecture, and no code path carries user content into one. What there is: an EmbedAsync() call whose output is stored under a scope key, and a GenerateAsync() call that sends retrieved context to whichever provider is configured. Both are disclosed up front rather than discovered — ADR-122 clause 6 puts the sentence in Studio’s publish flow, where a creator decides.
    • A guarantee the platform cannot enforce is not made. Content routed to GeminiProvider or OpenAIProvider leaves this system, and what a third party does with an API input is governed by that party’s terms. The covenant therefore rests on a contractual requirement with a named enforcement point — a release-checklist item verifying every configured provider carries a binding zero-retention, no-training term — and LocalOllamaProvider is the structural opt-out, the one path where the guarantee holds by construction rather than by contract. Studio’s BYOK path (Studio_Architecture.md §9 item 12) sends creator-keyed requests directly to a provider and never transits platform infrastructure, so no platform covenant reaches it and this document does not pretend otherwise.
    • A rulebook chunk carries no BranchId, and forcing one on it would have broken §7.3’s rule rather than satisfied it. These rows are Licence(sourceId) scope (ADR-042); campaign-derived summaries and embeddings are Campaign(RoomId, BranchId). A retrieval is therefore a composition of two scoped queries whose results are merged with provenance labels, never one query over a mixed corpus. That matters for correctness as well as tidiness: an answer can then never blend a rulebook citation with an abandoned timeline’s events without the blend being visible in the citation list. The rejected alternative — a sentinel BranchId on licence rows — would have made every query accept “sentinel or current branch”, i.e. two values, which is exactly the condition that permits cross-timeline blending. Duplicating the corpus per branch was rejected too: sixteen branches multiply embedding storage sixteenfold and give a revocation job sixteen chances to miss a row.

7.3 Time-Travel Replay Engine & Git-like Multiverse Branches

Because the foundational architecture relies on strict Marten Event Sourcing (recording a ledger of “all actions that occurred”), we integrate Git-like version control concepts to create a Time-Travel Engine that supports “multiverse branching”:

  1. In-Game Infinite Undo & Multiverse Branches (The Git-like Time-Fork):
    • Zero Data Deletion — and the axis that qualifier is measured on. If a GM triggers an “Undo”, reverting to historical node $N$, the discarded events are never deleted. That claim is true with respect to the timeline: no operation a player or GM performs inside the game destroys history, which is what makes infinite undo, branching and replay work at all. It is not a claim that the platform never deletes anything — §7.6 erases a subject’s identity linkage and §9.3 erases a subject’s authored Yjs items, both on request and neither reachable by any in-game action. Two different axes share one phrase, so the phrase is qualified here rather than left to a reader who meets the heading first and the exception four sections later.
    • Erasure survives undo, fork and checkout, and this is a property of ADR-042’s scopes rather than a separate mechanism. The question a reader should ask — can a GM undo their way back to before a player was erased? — has a clean answer: no. Erasure targets the identity linkage, which is Global-scoped, and Yjs items, which are TimelineIndependent. Neither scope is branch-keyed, so neither participates in undo, fork or timeline checkout (ADR-047, ADR-075), and replaying an older branch reconstructs the same game state around an opaque identifier. The join is worth stating because it is load-bearing and invisible: it holds only while those two artefacts keep the scopes ADR-042 assigns them, which is why ADR-078 registers a Depends-on against ADR-042 rather than trusting the property to remain true by luck.
    • Branch Is a First-Class Data Dimension (ADR-020): Marten provides no native stream-fork primitive, so forking is implemented explicitly rather than assumed. Streams are keyed {RoomId}:{BranchId}; a fork appends a TimelineForked{baseBranchId, baseSeq} event plus a materialized fork-point snapshot as the new stream’s origin. Without branch keying, a RAG query (§7.2) or a search result can answer from an abandoned timeline, and a rebuilt read model can blend two universes.
    • Scope replaces “always require a BranchId” (ADR-042). The rule as first written — BranchId is part of the key of every derived artefact, and the repository rejects any query omitting it — had the right goal and an impossible premise, because this same document produces two classes of artefact that cannot carry one: Yjs documents are deliberately branch-agnostic yet their flattened text lands in pg_trgm rows (§9.3), and a licensed rulebook belongs to no timeline at all (§7.2). Every derived artefact therefore declares exactly one Scope:
ScopeContentsKey
Campaignprojections, snapshots, campaign summaries and embeddings, explored FOW chunks (§5.3), per-viewer digests (§10.4), roll audit records (§4.1)(RoomId, BranchId)
TimelineIndependentYjs blobs and their flattened search rows, player notes, chat(RoomId, DocId)
Licencerulebook text chunks and embeddings(SourceId), filtered by the caller’s entitlement
Globalplatform-level data (bundle metadata, accounts)own primary key
  • An account is not an infrastructure tenant, and entitlement is structural (ADR-082). All customer data shares one PostgreSQL store and one schema; there is no per-tenant database and Row-Level Security is rejected explicitly, because the projection and lifecycle worker (ADR-043) legitimately reads across every account under a shard lease — any policy permissive enough for it defeats the purpose, and any policy strict enough breaks the worker. Saying so plainly matters more than the answer itself: a reader who assumes tenancy exists will not build the guards that actually carry the isolation. Those guards are exactly two. The typed scope surfaces below, and an entitlement context without which a Licence-scoped surface cannot be constructed — closing an asymmetry that previously made omitting a scope a compile error while leaving omission of the entitlement predicate within a scope an ordinary bug. §14.7’s generated disclosure tests accordingly cover cross-account cases and not only per-viewer visibility within a room, which is a different predicate. The projection worker remains the residual risk and is named as such, because it is the one component that reads across accounts by design and therefore the one the structural defence cannot cover.
  • Scope is a type, not a filter. It is expressed as separate repository interfaces per scope, so omitting it is a compile error where possible rather than a runtime check, and no method accepts a nullable BranchId. Cross-scope reads are explicit joins at the API layer — a character sheet is a Campaign projection plus a TimelineIndependent resolution of its prose, labelled as timeline-independent in the UI (§9.3) — and no scope may silently read another. A registry test enumerates every derived-artefact table and asserts each is registered under exactly one scope, because a missing registration is how the pg_trgm contradiction arose in the first place. Global is the hazard in this design: it is where things will be put to avoid deciding, so adding to it is a review-gated act.
  • Collaborative Content Is Timeline-Independent: Yjs documents are branch-agnostic and MUST NOT be projected into branch-dependent read models (§9.3) — enforced at compile time by ADR-042’s scope typing above: a TimelineIndependent artefact and a branch-dependent read model are separate repository surfaces, so the projection cannot be written without a type error. If a fork removes the entity that owned a document, the document is retained and surfaced in a “timeline-independent notes” tray; it is never re-attached to a recreated entity, which would carry a different UUIDv7 and silently produce two divergent backstories for one character.
  • Branch Budget: live branches per room are capped (default 16) with an explicit GC policy for unreferenced branches, because every branch multiplies read-model and embedding storage.
  • Parallel Timeline Branches: The system uses node $N$ as a base to fork a brand new “Timeline Branch” (analogous to git checkout -b new_branch).
  • Visual Node Tree: The frontend UI visualizes the event stream as a Git Commit-style “Tree Graph”. The GM can not only “Undo”, but also “Undo the Undo” (Redo/Checkout), freely clicking on any node in the tree, and the 3D world warps between parallel timelines.
  • Checkout Is a Bounded Out-of-Mailbox Saga, Not an Instant Operation (ADR-047). A checkout is an unbounded event replay (worst case: everything since the nearest fork-point snapshot) plus N per-viewer filtered Full Snapshots. Executed inside the Grain and described as instant, it lets a GM scrolling the node tree looking for the right 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. §5.2 had no line for it and §10.3 did not list it. Therefore:
    1. Checkout is build-then-commit, and the room refuses commands only for the swap (ADR-075). The operation is two phases, and the command-refusal window covers only the second:
      • Phase 1 — build and diff. The saga replays and builds the candidate state outside the mailbox, producing the diff alongside it. The Grain does not enter TimeTraveling and refuses nothing: the room stays fully playable. Progress is reported as CheckoutPreparing{progress}, an informational state rather than a refusal reason. The candidate is tagged (BranchId, SourceEventSeq, targetSeq) and held for Q-053, after which it is dropped silently — nothing was committed, so expiry has no user-visible consequence.
      • Phase 2 — commit. On confirmation the Grain enters TimeTraveling, performs the atomic swap, broadcasts the per-viewer Full Snapshot (ADR-023), and leaves the state. That window is bounded by the swap itself — no I/O, no human wait. Until the swap the room holds its pre-checkout state consistently, never an intermediate one.
      • A candidate whose room advanced is invalidated, never silently swapped. Phase 1 does not freeze the room, so events may commit while the candidate is being built or reviewed; on commit, a SourceEventSeq that has moved invalidates the candidate and it is rebuilt, with the GM told plainly that the room changed while they were looking. This is the third independent mechanism in this document needing a read at T, apply at T+k premise check — after ADR-046’s Effects and Guardrail 4’s Intents — which is strong evidence it belongs in one shared platform primitive rather than three implementations.
      • Why the split, rather than a preview timeout: ADR-070 requires a GM to review a state-migration diff before a cross-major checkout commits, and any timeout long enough to read one is long enough to freeze the room past complaint, while any timeout short enough not to freeze the room is too short to read. That trade has no acceptable middle value, which is what makes splitting the phases the answer rather than tuning one. The split improves ordinary checkouts too, which had no reason to refuse commands during the replay either.
    2. Rate-limited and debounced: a per-room token bucket plus UI debounce, so scrolling the tree executes only the last selection. Rate limiting alone would not have helped — it bounds frequency, not the cost of one replay.
    3. Browsing the tree reads T2, it does not replay. Node summaries (time, actor, title) come from a projection, which is what makes scrolling cheap; per §2.1.1 they are advisory only and carry their SourceEventSeq, so the tree can show that it is behind during a rebuild.
    4. A distant target is a progress-bearing wake state, labelled distinctly from the other waits per §9.5 Guardrail 6 — not an operation pretending to be instantaneous. The word “instantly” is withdrawn from this feature: it is a real experience regression against what was previously claimed, and it buys a room that cannot be frozen by its own GM.
    5. Per-account checkout concurrency is capped (§10.3 face 6), because one account scripting tree-scrubbing across fifty rooms is otherwise a cost-amplification vector.
  • Timeline Decoupling Principle (CQRS vs Yjs): The Undo operation only rewinds “physical entity states” governed by CQRS (e.g., HP, positions). “Player collaborative knowledge” governed by Yjs CRDT (e.g., chat logs, notebook drawings) transcends the game timeline. Even if combat time travels backwards, notes recently written by players are never deleted, perfectly aligning with player psychology in TRPGs. The permanence is against the timeline, not against the author. A player’s notes survive every undo, fork and checkout the table performs; they do not survive that player’s own erasure request, which removes their items under ADR-080 and is the one deletion path into this substrate. Stating both halves matters because the sentence is a promise made to players, and a promise whose exception is discovered later is worse than a narrower promise made now.
  1. Post-Campaign Cinematic Replay (Client-Side): After a campaign concludes, players can download a campaign_replay.fbs (FlatBuffers archive) containing zero 3D textures. When the frontend switches to “Replay Mode” it detaches from the network and the Web Worker plays the archive back in local memory, allowing pause, fast-forward, and 3D perspective switching with zero server compute cost at playback time — see the two qualifications below, because the unqualified version of that sentence is not true.
    • An export leaves the platform permanently, which bounds what erasure can promise. Per-viewer filtering limits what any one export contains, and nothing limits how long it exists once downloaded: §7.6’s subject erasure reaches platform-held data and cannot reach a file on a GM’s disk. ADR-060’s session-scoped content keys make protected bundles revocable and do not extend to exported event data — a distinction worth stating here, at the point a reader forms the expectation, rather than only where erasure is specified.
    • Generating the archive is an offline job, not a request (ADR-059). Building it means re-running the campaign through the same per-viewer visibility filter as live replication, which is work on the order of §7.4’s “tens of seconds” for a 300k-event campaign. Served from the request tier, two hundred players requesting exports in the hour after a convention would each pin a core for minutes on the same tier serving live rooms, and KEDA’s response — more empty silos — does not help an occupied room. Export therefore runs in the event-driven Jobs pool (§6.2’s pattern) with per-account concurrency and daily caps, delivered to R2 behind a short-lived link, and is enumerated as EDoS face 6 in §10.3.
    • An archive records the versions it was resolved with — expressed as a rule, because an enumeration goes stale (ADR-059). Reproducing “what that viewer could see” requires the Descent.Geometry revision and cartridge versions of the time; re-resolving under current versions produces a file that disagrees with the player’s memory. The normative form is therefore: an archive records the version of every versioned first-party artefact that affects its presentation, and states which mode produced it. Currently that is the geometry crate revision, the cartridge set, the ADR-041 dice trajectory table, the font subset (Q-032), and the §9.4 effect classes. The trajectory table was missing from the original list and is the reason this is now a rule rather than a list: dice motion is an authored presentation event, so a pipeline re-run would have made old campaigns replay with different dice — contradicting this clause’s own principle that a record claiming to be history and failing to be one is worse than a record admitting its limits. Where the original cartridge can no longer be loaded (§3.1), the export still succeeds — via ADR-020’s RetiredEvent envelope — with the affected spans explicitly marked. Silently re-resolving and presenting the result as a historical record was rejected: a record that claims to be history and is not is worse than one that admits its own limits.
    • A deleted custom asset degrades the same way, and reuses the same state rather than inventing one (ADR-119). A storage quota creates a deletion pressure the corpus did not previously have: an account at Q-085 frees space by removing a custom asset, and a §7.3 replay of a session that referenced it then names bytes that no longer exist. That replay renders labelled placeholder geometry — the identical destination a revoked licence reaches, on the reasoning ADR-M-025 records for RulesetReadOnly: a fourth cause of an existing state must reuse it rather than introduce a fifth behaviour. What the placeholder says differs (deleted by its owner, not licence-revoked) because a player must be able to tell an action they took from one taken about them; what it does does not.
    • Protected assets are unavailable offline. Because content keys are session-scoped and never persisted so that revocation can work (§6.1, ADR-060), a network-detached replay renders protected bundles as labelled placeholder geometry and pins only unprotected baked assets, under an LRU exemption tied to the replay file’s lifetime. For campaigns using protected content, network-detached replay is therefore partially available.
    • The Archive Is Per-Viewer Filtered, Server-Generated (ADR-022): the raw event stream contains GM Channel and Secret Channel history — true monster stats, secret rolls, unrevealed map regions, private messages. Exporting it wholesale would hand every player every secret the GM ever entered, bypassing the §8.2 visibility model, which only filters the live replication path. The archive is therefore built server-side through the same per-viewer filter as live replication, scoped to one requesting identity, with an optional GM “declassify” toggle per channel. A GM export and a player export of the same campaign are different files by design.
    • Declassify is bounded to concealment the GM authored (ADR-084). It reaches the GM Channel and the Secret Channel — handing players the monster stats and the secret rolls once a campaign ends, which is what it was written for — and it may never widen the disclosure of content another participant authored, which is what §8.2’s Player Channel carries. The scope was previously unstated, and the permissive reading of “per channel” would have exported one player’s private content to another through a supported feature working exactly as configured. This is ADR-073’s principle — no mechanism may widen a viewer’s disclosure set — applied to a different machine; the two are stated separately because neither implies the other. A toggle able to lift a channel’s meaning would make that meaning unstable for every consumer of it: the live replication path, this archive builder, and §14.7’s generated disclosure tests all depend on the channels meaning one thing. A declassified export records which channels it lifted, so an archive is self-describing rather than indistinguishable from a filtered one. A product may later decide a GM can obtain another participant’s content, but that is a consent-bearing feature with its own record, not a flag.
    • The Archive Contains Reduced State Deltas, Not Raw Commands: replay cannot re-derive state from commands, because the rule engine that gives commands meaning is deliberately server-side (§12.2.6) and is not shipped to clients. The exporter therefore emits already-resolved visible state deltas plus presentation events — what that viewer could observe, frame-ordered — which also means a filtered export stays internally consistent (a player sees their own damage without needing the hidden attack roll that caused it).

7.4 Cold Storage & Data Lifecycle Hydration Strategy

To achieve extreme database cost optimization (FinOps), the system implements an automated “Cold/Hot Data Tiering” mechanism, preventing indefinite PostgreSQL bloat from abandoned campaigns:

  1. Cold Tiering, Not Deletion (ADR-036, superseding ADR-019): Events are never removed from the database’s logical custody, because the one guarantee Event Sourcing cannot trade away is that any read model is rebuildable from the events. ADR-019’s reasoning for rejecting delete-to-object-storage is retained in full and is still why this design has the shape it does: re-inserting events below the Marten async daemon’s global high-water mark means they are never projected, and re-inserting them above it double-applies an entire campaign. Both failures are silent and surface months later. What did not survive review was the mechanism.
    • The partition key must be immutable, and “room activity epoch” is not. A PostgreSQL partition key is a fixed attribute of the row, while a room’s activity is a property that changes over its life. Fix the epoch at write time and it degenerates into “event creation time” — so a three-year campaign’s events are spread across three years of partitions, and detaching any one of them takes an active room’s history with it. Let the epoch change and the key must be UPDATEd, which PostgreSQL implements as delete-plus-insert into the new partition: the very re-insertion ADR-019 exists to prevent. There was no third reading.
    • mt_events and the Yjs blob tables are therefore partitioned on an immutable hash(room_id) into a fixed count (a migration-visible constant), and cold tiering operates on the room as the unit, not on a partition. An inactive room’s events are relocated to room-scoped cold storage by a bulk move that preserves seq_id, is verified read-after-write with a checksum, and retains an overlap window before any local reclaim — executed by the projection/lifecycle worker (ADR-043) under a shard lease, never by a request-tier instance. Per-room partitioning was rejected: tens of thousands of partitions inflate query planning and catalogue size, and ATTACH/DETACH on the parent serialise against each other, so concurrent cold-room wakes queue behind one another.
    • What this costs, stated plainly. ADR-019 claimed “no COPY is required”; that claim is withdrawn — a room-scoped move is COPY-class I/O. What is preserved is the stronger and actually load-bearing property: nothing is re-inserted into the live sequence range, which is the real reason the high-water mark stays valid. The cost moves to an offline worker with a verified overlap window rather than disappearing. A retired mechanism does not retire its diagnosis.
    • The campaign’s “Metadata, Plaintext Summaries, and AI Vector Embeddings” are permanently retained in hot storage (under Campaign scope per §7.3), keeping global search and RAG queries functional and timeline-correct.
    • The room-scoped set that relocates together, enumerated rather than implied. “Archives with the room” was asserted for artefacts the partitioning paragraph never named, so the set is stated: mt_events, the Yjs blob tables, explored FOW chunk rows (§5.3, keyed (RoomId, BranchId, ChunkId)), sampled diagnostic retention (§10.4), and T0 snapshots (§7.1). Only the first two are partitioned on hash(room_id); the rest relocate by room key. FOW chunk rows carry no seq_id, so ADR-019’s argument about never re-inserting below the projection high-water mark does not apply to them — which must be said rather than left for a reader to derive, because that argument is the centrepiece of this section and it is reasonable to assume it governs every table the section mentions.
  2. Edge-Hold & Rehydration: When a player enters an archived room, the frontend displays a waking animation (Edge-Hold). Rehydration restores the room’s events with their original seq_id, followed by a targeted, per-room projection replay driven by a per-room progression cursor rather than the global daemon high-water mark. This work runs in a hydration saga outside the Grain mailbox (§4.4): the RoomGrain reports Hydrating with progress and continues answering other callers instead of blocking every queued intent behind a long restore.
    • Two progress mechanisms coexist, so idempotency is now normative. Because a rehydrated room is replayed by a per-room cursor while everything else advances on the global mark, every projection must be idempotent per (stream, version). This was previously implicit; it constrains how projections may be written (no non-idempotent side effects such as increment-style counters) and is asserted per projection in CI.
    • Honest budget: projection replay for a ~300k-event campaign is measured in tens of seconds and is presented as a distinct, progress-bearing wake state — never conflated with the container cold start of §9.5 Guardrail 6. The earlier claim that rehydration is “a catalog operation measured in milliseconds” described ATTACH PARTITION in isolation and is superseded along with the mechanism it belonged to.

7.5 Worked Migration: a Cartridge Removes an Attribute

§7.1’s three upcasting constraints are correct and insufficient, and the gap only becomes visible against a concrete case. Take the hardest realistic one: Descent.Vtt.Plugins.CoC7e v3.0.0 removes the attribute sanity.bouts — an integer counter of Bouts of Madness suffered — and replaces the mechanic with Condition entities. Two event types carried it (SanityLostEvent.boutsTriggered, BoutOfMadnessBeganEvent), the attribute registry defined it, character sheets display it, and community macros may read it. Campaigns three years old must still open, replay, and export.

7.5.1 First, Separate Three Things That Are All Called “Migration”

The single most expensive mistake available here is treating this as one problem:

  1. The event streamwhat happened. It must never lose information, so no upcaster ever deletes a field. An upcaster that strips boutsTriggered is not a migration; it is destruction of the audit record that §7.3 replay, §10.4 incident reconstruction and every export archive depend on. Storage is not the constraint that would justify it.
  2. The read modelcurrent state. Derived, disposable, rebuilt shard-scoped under ADR-043. It drops the key and nothing is lost.
  3. The rules enginewhat the numbers mean. This is the actual problem, and neither upcasting nor projection rebuild addresses it at all.
  4. Entity statewhat a value becomes when the shape it lived in is gone. Distinct from an event upcaster, and needed the moment a timeline checkout crosses the boundary (ADR-070).
  5. The T0 snapshotthe accelerator’s own format. Never upcast; discarded and replayed on a version mismatch, with that replay pre-empted by scheduled pre-generation (ADR-076).

This list began as three and is now five, which is itself the lesson. Items 4 and 5 were each invisible until a specific act exposed them — a time-travel across a cartridge major, and a snapshot schema bump — and neither was reachable by reasoning about the first three. The heading’s claim that the most expensive mistake is treating this as one problem is more true than when it was written; the maintenance obligation it implies is that this table is re-walked whenever a new persisted artefact is introduced, because a taxonomy that has been extended twice is a taxonomy that will be extended again.

7.5.2 The Rules Engine Is Never Re-Run Over History (ADR-069)

Replaying a 2027 combat under the 2029 rules engine produces 2029’s answers. If the event stream records inputs to be adjudicated — “spent 3 MP casting X” — then every future rule change silently rewrites what happened at the table, and a campaign’s history becomes a function of the publisher’s release schedule. Upcasting cannot rescue this, because the payload deserialized perfectly; it is the interpretation that changed.

Domain events therefore record adjudicated outcomes, with their inputs attached as evidence rather than as instructions. SanityLostEvent records the resulting Sanity value and that a bout was triggered; it does not record a roll to be re-evaluated. The consequence is stated plainly because it constrains every cartridge author: the system never re-adjudicates. Projection rebuild replays outcomes into a read model; it does not replay decisions through the rules engine. This is what makes ADR-022’s replay and export archives immune to rule changes by construction, and it is why “replay” in this document has never meant re-simulation.

7.5.3 What Happens to Each Artefact

ArtefactAction on v3.0.0Why
Historical event payloadsNothing. boutsTriggered stays in every event ever written§7.5.1 rule 1. The field is evidence, not state
T0 snapshots (if the change alters snapshot shape)SnapshotSchemaVersion bumped; new snapshots pre-generated shard-scoped before the version goes live; any missed one discards and replaysADR-076. A snapshot is an accelerator, not evidence — it is never upcast, but the replay it falls back to must be scheduled rather than discovered
BoutOfMadnessBeganEvent (type removed)Terminal upcaster → RetiredEvent{originalType, payload}ADR-020 constraint 2. Replay and export render it as a labelled historical entry rather than failing to resolve a type
Attribute registry entryTombstoned, not deleted: Status = Retired, RetiredIn = 3.0.0, DisplayName and type bounds retainedSee below — this state does not currently exist
Character-sheet read modelKey dropped at the next shard-scoped rebuild (ADR-043)T2 is derived
T1 snapshotsNo action; written at current schema versionADR-020 constraint 3 collapses the chain
Export archiveAlready records the cartridge version it resolved with (ADR-059)The archive is self-describing, which is what makes a historical rendering legible
Timeline checkout to a pre-3.0.0 tick (§7.3)The hard case — §7.5.5Resuming live play from historical state is not replay

The registry needs a retired state and does not have one. IAttributeDefinition (§7.1) has no lifecycle: a definition exists or it does not. Delete sanity.bouts and a three-year-old sheet renders sanity.bouts: 4 with no label, or the JSON-AST renderer fails on a descriptor referencing an unregistered key. Retaining the definition with a retired status is what lets historical data stay readable while stopping it from being writable — and it is the same shape as ADR-055’s ruleset-read-only state, one level down.

7.5.4 Removal Is a Two-Release Deprecation, Not One (ADR-071)

A cartridge that removes an attribute in a single release breaks in-flight campaigns mid-session, and the GM’s only warning was a changelog they did not read. §3.1 already established a deprecation window for SDK majors; the same rule applies one level down and was never stated:

  • v2.9 deprecates: the registry marks the attribute deprecated, sheets render it read-only with a stated reason, new events stop writing it, and the GM sees a campaign-level notice naming the attribute and the release that will remove it.
  • v3.0 removes the mechanic, with the tombstone above.
  • A campaign pins its cartridge major and opts in to the upgrade. This is the most consequential sentence in this section: without it, a third-party publisher can break someone’s paid campaign in the middle of an arc, on their own release schedule, and the platform has no answer. ADR-055 already built the equivalent escape for SDK majors; cartridge majors need the campaign-level analogue, and the GM chooses when to cross it.
  • Removal is checked against a reverse-dependency index at publish time. sanity.bouts may be an input to another cartridge’s formula, a community macro (§6.4), or an SDK consumer (§12) that the removing author cannot see. The registry must therefore answer “what reads this key”, and publishing a removal that breaks registered dependents is refused with those dependents named. Without this index, “we removed one attribute” is a change whose blast radius is unknowable by anyone, including the platform.

7.5.5 Checkout Across a Cartridge Major Is a Migration, Not a Navigation (ADR-070)

§7.3 lets a GM check out an earlier point on the timeline and continue playing from it. That is not replay: it produces live state, adjudicated forward, by the currently loaded cartridge. Check out a tick from before v3.0.0 and the resulting state contains sanity.bouts, for which the loaded v3.0.0 cartridge has no rules. Three options exist and two are wrong:

  • Re-adjudicate under v3 rules — silently rewrites what happened, and §7.5.2 forbids it.
  • Load the v2 cartridge — §3.1 forbids it (an assembly-loading boundary, not a deserialization one), and routing it to ADR-055’s compatibility environment moves the whole campaign, not one checkout.
  • Forward-migrate the state at checkout, explicitly — the only defensible answer, and it requires a mechanism the platform does not currently have.

Event upcasters and state migrations are different artefacts doing different jobs. An upcaster maps an old event to a new event shape. A state migration maps old entity state to new entity state — here, four accumulated bouts to the equivalent Condition set. Nothing in ADR-020 provides the second, and the omission is invisible until someone time-travels. Therefore:

  1. A cartridge major that changes state shape declares a state migration alongside its upcasters.
  2. A checkout crossing that boundary runs it, previews the diff to the GM before committing, and forks to a new branch rather than mutating the existing timeline — consistent with ADR-047’s atomic-swap saga and with §7.3’s fork semantics. The preview sits in Phase 1 of ADR-075, where the room is still playable — it is a human decision point, and placing it inside a command-refusal window would have re-frozen the room that ADR-047 exists to keep unfrozen, this time for an unbounded period that neither rate limiting nor debounce can reach.
  3. A checkout crossing a boundary with no declared state migration is refused, naming the blocking cartridge — the identical failure mode and identical remedy as ADR-055’s per-room admission. Refusing with an actionable name beats opening a session whose numbers mean something nobody can state.

7.5.6 How Any of This Is Known to Work

An upcaster’s correctness is a claim about events written years ago by cartridges that may no longer load, so it cannot be demonstrated by a unit test written against today’s event shape. It is demonstrated by the recorded-session corpus of §14.5 (ADR-065): real streams from every cartridge major still in service, replayed in CI, asserted to produce identical projected state. A migration path with no archived stream from before the change is untested by construction — which makes retaining those streams a release requirement rather than an archival nicety.

7.6 Subject Erasure Against an Event Store That Never Deletes (ADR-078)

§7.3 states Zero Data Deletion and §10.4 rests projection rebuild on events never being deleted. Both are true with respect to the timeline — no in-game action destroys history — and neither is a claim about identity. §7.3 now names that axis at the point it makes the claim; this section is the other half. The difference between the two axes is what makes a deletion request answerable at all, and erasure runs in the opposite direction to undo: it is reachable only by a subject’s request, never by a GM’s timeline operation, and correspondingly an undo, fork or checkout cannot resurrect what it removed (§7.3).

The event store is already pseudonymous, and that is the load-bearing fact. §9.3’s Entity Segregation stores only a UUIDv7 link where an entity carries long-form text; §5.1’s ActorState is position and orientation; ADR-069 keeps events to adjudicated outcomes with their inputs as evidence. What an event holds about a person is an opaque identifier. The name behind it lives somewhere else. That property was built for entity-model hygiene and ID-collision avoidance and was never claimed as a privacy property — which is why ADR-078 promotes it from a happy consequence to an enforced invariant, on the same reasoning as every other property in this document that survives only while someone remembers it.

  1. No domain event, T0 snapshot or T1 payload may carry a personal attribute — a name, email address, handle, credential, contact detail, or prose authored by a person. Enforced by Descent.ArchitectureTests against a closed personal-attribute vocabulary in Descent.Vtt.Domain. The honest limit is stated with the rule: the test rejects the attribute types it knows, and a personal attribute smuggled through as an untyped string passes it.
  2. The identity linkage is a Global-scoped record (ADR-042) outside the event store, and erasure of a subject is deletion of that record. Every event keeps every byte, seq_id stays contiguous, and ADR-036’s archival and ADR-043’s shard-scoped rebuild are untouched — nothing is removed and nothing is re-inserted, so ADR-019’s retained argument is not engaged. Erasure and the never-delete invariant are both satisfied by construction, not by trading one against the other.
  3. A field that must carry a personal attribute inside an event is a declared exception, held as ciphertext under a per-subject key stored outside the event store, erased by destroying the key. Each exception requires its own ADR, because the cost is unrecoverable: a field that reaches an event in plaintext before being declared can never be shredded afterwards.
  4. Derived artefacts clear through mechanisms that already exist — T2 read models by ADR-043’s shard-scoped rebuild, T0 snapshots by the discard-and-replay path ADR-076 built for schema mismatch.
  5. Erasure is an offline job with a completion deadline (Q-054), and no backup of the keystore or linkage store is retained beyond it (Q-055). Enforced by the ErasureBacklog SLO, which alerts when the oldest pending request approaches Q-054, and by a named release-checklist item asserting Q-055 ≤ Q-054. A restore that outlives the deadline resurrects what was erased, which makes the rest of this section decorative — the erasure job still completes, the metric still goes green, and the data comes back on the next restore drill. This is the least architectural clause here and the easiest to violate quietly.

Yjs-held content is covered separately, by ADR-080 (§9.3). It is a harder problem — co-authored in a CRDT, written a second time as flattened search text, and replicated into clients’ IndexedDB — so it gets its own mechanism rather than an extension of this one. The two are not alternatives: ADR-078 erases the identity linkage, ADR-080 erases the authored items, and a subject erasure runs both.

One exclusion remains, stated rather than left to inference. Exported archives already delivered to a user are unreachable by any platform mechanism. ADR-060’s session-scoped content keys make protected bundles revocable and do not extend to exported event data — a distinction a reader is otherwise likely to get wrong in the platform’s favour. Per-viewer filtering (ADR-022) bounds what a third party’s export contains; it does not make it recallable. The accumulation is routine rather than exceptional, and saying so is the honest form: a GM who exports weekly — ordinary prudence, using a button the platform provides — holds a rolling copy, so an erasure is defeated by expected behaviour rather than by an unlucky old file. No architectural control is available; this is the limit every data-export feature has. What follows is a disclosure obligation rather than a mechanism: a player should be told that their GM has a copy, not discover it. ADR-084’s requirement that a declassified export record which channels it lifted is the closest thing to visibility the platform can offer here.

What erasure does not achieve. After it, a subject’s events remain linkable to each other through the retained opaque identifier. The link to a person is destroyed; the pseudonymous trail is not. Whether that suffices is a legal question this document does not answer and does not pretend to.

8. World State Replication & Visibility Channels

8.1 Hybrid Serialization Strategy (FlatBuffers + MessagePack + MemoryPack)

  • Client Communication (FlatBuffers, 0 per-frame allocations): High-frequency core engine updates (e.g., token movement) use FlatBuffers. The Descent.Vtt.Protocol project defines the .fbs schema. The accurate claim is one copy, zero per-frame object allocations: the received frame is copied once into the SharedArrayBuffer by the network worker (§9.6.1), after which field access reuses pre-allocated accessor objects so steady-state rendering performs no allocation and triggers no GC. It is not zero-copy, and the copy is budgeted.
  • Single Schema Source & Wire Versioning: Descent.Vtt.Protocol is the only source of truth for the wire format and generates the C# and TypeScript artefacts from one .fbs set, so the wire shape cannot drift into hand-maintained definitions per language. flatc is the sole wire compiler, and generated sources are never committed (ADR-092). FlatSharp is refused despite being the convenient .NET answer: it emits C# only, so adopting it leaves the TypeScript artefact hand-written — the single-source property is cross-language or it is nothing, and a C#-only generator satisfies the letter of “generated” while abandoning the half the claim exists to protect. flatc and the Google.FlatBuffers runtime are version-matched with the runtime pinning the pair, because generated code asserts FlatBufferConstants.FLATBUFFERS_<version> and the NuGet package trails the compiler releases. Every packet carries a protocolVersion and, for embedded cartridge payloads, a cartridgeSchemaVersion; a client presenting an unsupported pair is refused at handshake rather than misparsing an opaque [ubyte] blob.
    • The cache is not a third generated target (ADR-058). This bullet previously claimed the .fbs set also generates “cache-side artefacts”. It does not and cannot: MemoryPack is a C#-only, [MemoryPackable] source-generated serializer and flatc emits no such types. Rather than write a custom generator or version two shapes against each other, the second shape is eliminated — room snapshots in cache are stored byte-identical to the T1 payload (§2.1.1), so the single-source property holds for that path by construction. This closes a failure mode that sequence validation could not catch: a DTO missing a field the Marten snapshot has yields a matching SourceEventSeq and wrong state, so the room behaves differently depending on whether the cache was warm.
    • Refusal only works if the client can then update itself (ADR-054). A handshake refusal that instructs the player to upgrade, combined with §9.6.6’s Cache-First policy for code, is a lockout: the Service Worker re-serves the same stale bundle on every reload, and the only escape — clearing site data — destroys OPFS assets and unmerged Yjs edits. Since this protocol has just gained four new surfaces (lease grant push, terminal peer packet, staleAnchor, advisory position uplink — §5.1.1), version changes are certain rather than hypothetical. Therefore: an unauthenticated cacheable endpoint publishes the minimum supported build and protocolVersion so the client checks before connecting, code caches are keyed by build id, the app shell is never Cache-First, and a refusal triggers a bounded forced update that flushes offline Intents and unmerged Yjs first. §9.5 Guardrail 6 must also distinguish “server waking” from “client too old” — different states, different user actions, and only the first is a wait.
  • Client Communication (MessagePack Payload) — its own message, not an embedded one (ADR-092): Dynamic, cartridge-specific structures (e.g., CoC7e Sanity) are serialized as MessagePack bytes, preserving cartridge schema flexibility without binding it to the core wire schema. They travel event-driven and low-frequency — when a stat changes, or when a client opens a sheet — and never inside the tick-driven snapshot.
    • This bullet previously said “embedded opaquely as [ubyte] within the FlatBuffer packet”, and that was withdrawn on measurement (ADR-092). For §5.2’s reference interest set of 200 entities a transform-only snapshot is 8,068 bytes; the same snapshot with every entity carrying Q-060’s 4,096-byte attribute ceiling is 827,268 bytes, which at Q-001’s 20Hz is ~16.5 MB/s per viewer against a budget ADR-014 exists to protect. (Re-measured 2026-08-09: these were 6,468 and 825,668 when ADR-092 was ruled, against a 32-byte DisclosedEntity; the struct is 40 bytes since elevation and facing were added. Q-056 is unmoved and Q-056’s registry entry carries the full derivation and the reason the eight bytes were accepted into the tick payload rather than decoupled.) The embedding is what made that frequency coupling invisible — the format assignment was right and the packet boundary was wrong, and nothing in the original wording distinguished the two. Q-056’s 32,768 is set on the decoupled shape and is one decision with it.
  • Fog masks are also out of band (ADR-092). Q-010 fixes a packed chunk at 16 KiB, sized for storage and for the geometry crate rather than for a tick; at 20Hz a single chunk would be ~320 KiB/s per viewer, more than fifty times the transform payload it would be attached to. Its cadence is already governed separately by Q-068Q-071’s ladder, so bundling it would silently re-couple two cadences §5.3 deliberately separates.
  • Backend Cache (FusionCache / Garnet): cache payloads other than room snapshots use MemoryPack for zero-allocation, reflection-free serialization that is also Native-AOT-compatible (§10.1), keeping the cache path allocation-free even though the host runs on JIT. These payloads (session data, computed lists) are reconstructible and short-lived, so a shape change is handled by including the app build id in the cache key and letting a deploy invalidate them wholesale. Room snapshots are the exception and are stored byte-identical to T1 (ADR-058).

8.2 Visibility Channel Replication

Combines Area of Interest (AOI) spatial filtering with Visibility Channels:

graph TD
A[Server World Model State] --> B[Interest Management: AOI Grid Filter]
B --> C[Visibility Channels Filter]
C --> D[Public Channel<br>Visible to all players]
C --> E[GM Channel<br>Visible only to GM]
C --> F[Player Channel<br>Visible to specific player]
C --> G[Secret Channel<br>Internal system authoritative channel]
D --> H[Delta Replication: Bitmask Diffs]
E --> H
F --> H
G --> H
H --> I[Client Prediction & Reconciliation]
  1. Full Snapshot (always per-viewer): Dispatched when a player first connects. Every artefact named “Full Snapshot” anywhere in this document — including the mandatory broadcast after a time-travel or fork (§7.3) — is filtered through that viewer’s AOI and Visibility Channels. There is no such thing as an unfiltered world snapshot on the wire; a single unfiltered broadcast would disclose every GM and Secret channel entity in one packet.
  2. Delta Sync: Transmits bitmask diffs for changed properties. A delta is only ever sent against a baseline the server knows that client holds; each delta carries the baselineVersion it applies to, and a client detecting a gap requests a re-baseline instead of applying a diff to zeroed fields.
    • The baseline’s storage layout is normative, not an implementation preference. Per-viewer baselines are held as contiguous version arrays indexed by a dense entity slot, never as dictionaries or boxed values, and a cartridge’s MessagePack sub-payload is serialised once per entity per tick with its bytes shared across every viewer packet. §5.2’s Segment B budget is derived assuming both: a dictionary-backed baseline raises per-comparison cost by an order of magnitude and the delta pass alone consumes the whole segment, while per-viewer re-serialisation makes cartridge payloads O(viewers × entities) instead of O(entities). These are the two levers that decide whether a 50-seat room fits in its tick at all.
  3. AOI Entry Requires a Baseline (ADR-023): AOI entry is a first-class state transition, not a side effect of panning. When an entity enters a viewer’s interest set — because the viewer moved, the camera panned, or the entity moved — the server emits a per-entity baseline before any delta for it. Without this, a newly-interesting entity’s first packet is a diff against nothing, and the entity either materialises at the origin with zeroed attributes (appearing dead at 0 HP) or is dropped and stays permanently invisible while server logs look clean.
    • This is satisfiable for peer-driven entities only because of staleAnchor. A leased entity’s transform is suppressed from the snapshot (§5.1.1), so a viewer whose AOI includes it mid-drag would otherwise have nothing to baseline against. The PeerDriven marker’s labelled staleAnchor is that value.
  4. Entity disclosure is gated on maskTick, not transformTick. Because the tick is pipelined (§5.2), a snapshot’s transforms can be one tick ahead of the visibility mask that governs them. A client therefore discloses an entity only when the older of the two ticks covers it. Without this rule, pipelining would let an entity be drawn for one tick before the fog that conceals it — a 50ms disclosure of exactly the information §9.2 exists to protect.
  5. Client Prediction & Reconciliation: Clients predict their own entity’s movement over geometry they have been disclosed, using the shared geometry core (§5.3), and reconcile on server confirmation subject to the ownership-lease rules in §5.1.1. Client-side visibility is presentation of the authoritative mask, never prediction of it (ADR-034) — see §9.2 for why the distinction is a security property rather than a performance one.
  6. Scheduled, not merely open: visibility is currently entity-granular, and the ruleset needs field granularity (ADR-074). The four channels above express whether a viewer sees an entity, not which of its fields. Call of Cthulhu play depends continuously on partial disclosure — an investigator knows the thing in the corridor exists without knowing its remaining Hit Points, its true name, or whether the Keeper has already rolled for it. The bitmask delta machinery of item 2 can carry per-field visibility, and it shares a substrate with the field-level premise versions §4.3 already requires for Effect validation, so the two are one workstream with one owner rather than two mentions of a shared need. It was recorded as a known design gap rather than an implied capability — correctly, because a reader could otherwise assume the channel model already covers it — but a gap noted in three places and owned in none is a gap that reaches implementation. ADR-074 assigns it, and its completion lifts ADR-046’s condition in the same act.

8.3 Snapshot Interpolation & Netcode Jitter Resolution

To resolve visual stuttering (jitter) caused by unstable player networks during 3D token movement, and to significantly reduce server load, the system implements fixed-tick Snapshot Interpolation:

  1. Fixed Server Tick & Batched Broadcasts: The Orleans server broadcasts authoritative physical state “Snapshots” at the fixed 20Hz tick (§5.2) rather than echoing every incoming packet. This decouples server cost from client input rate, so a high-polling-rate mouse or a modified client cannot linearly amplify room-wide CPU and bandwidth.
    • Targeted Per-Silo Forwarding, Not Backplane Fan-Out (ADR-032, superseding ADR-024): snapshots MUST NOT traverse the generic SignalR backplane. A Redis/RESP backplane publishes every group message to every silo, which each then filters — so 500 rooms at 20Hz would force ~10,000 publishes/sec to be received and deserialized 8 times over, most of it irrelevant to the receiving silo, with the single cache container as the choke point. That diagnosis stands and is the reason this design has the shape it does. Enforced against the broadcast API rather than the payload type (ADR-090): CI scans the Hub surface by reflection and fails the build if a backplane-routed broadcast — Clients.All, Clients.Group(...), Clients.Groups(...) or the IHubContext equivalents — is invoked from the snapshot dispatch path. Scanning instead for Hubs that carry Snapshot types would fail the build on the correct implementation, because SignalR is the client transport (ADR-029) and the mechanism below ends with a local dispatcher writing to its own hub connections; a Hub carrying snapshots to clients is right, and routing them through the backplane is wrong. Because a static check binds only on code in this repository, a runtime SLO asserts that backplane publish rate does not scale with tick rate × room count, catching a route introduced by a library upgrade or a configuration change. A SignalR maximum message size (Q-056) is adopted as defence in depth and bounds blast radius rather than routing — size and routing are independent, and a 5 KB snapshot published to a group fans out exactly as a large one would.
    • What did not stand was the prescription. Routing all connections for a RoomId to the silo hosting that RoomGrain requires the edge to know grain placement, and two independent facts prevent it: replicas behind a managed HTTP ingress are not individually addressable by an application key — the same property §10.1.1 uses to disqualify Cloud Run from multi-silo clustering — and Orleans placement is dynamic anyway (idle deactivation, scale-in, rebalancing) while §4.4’s weighted director decides placement independently. Any edge-held mapping is therefore stale by construction, and the failure is silent: after a migration the tick is produced on one silo while the connections sit on another, with no local delivery path and the backplane forbidden. Every player’s 3D world freezes and every server signal stays green.
    • The mechanism is two-hop and in-cluster. A per-room connection registry records which silos hold connections for that RoomId; each tick the RoomGrain groups the per-viewer payloads it already produces by owning silo and sends one message per involved silo, whose local dispatcher writes to its own hub connections. Cost is O(silos holding this room’s viewers) — at worst 8 messages per tick for a room spread across 8 silos — rather than O(all silos) for every room in the cluster. The registry is derivable from live connections and therefore never persisted; a persisted one would become a stale fourth authority.
    • Sticky ingress is retained as a pure optimisation. When it works, delivery is one hop; when it fails or a grain migrates, delivery still works at the cost of one in-cluster hop. No correctness property may depend on it. The backplane remains reserved for low-frequency cross-room concerns (presence, notifications, admin broadcasts), which is what it is good at.
    • This makes silo-to-silo reachability inside the environment a blocking dependency, not merely the release-gating validation §10.1.1 lists it as. If it proves unavailable, the fallback is one silo per deployed app — giving each silo a stable internal FQDN — with an explicit room→silo assignment table written by the placement director and read by the edge router, so that placement and routing share one source of truth instead of the edge trying to infer it.
  2. Adaptive Client Buffering: Upon receiving a snapshot, the frontend temporarily stores it in a buffer, intentionally withholding rendering for a brief moment. The system employs an Adaptive Buffer Size based on real-time network jitter: shrinking to ~30ms for fiber connections to maximize responsiveness, and expanding to ~150ms on unstable connections to guarantee smoothness.
    • The buffer adapts to jitter, and it never has to adapt to the server. This is why §5.2’s degradation sheds fidelity rather than reducing the snapshot cadence: the interpolation interval the client sizes against is a constant. Halving the tick rate under load would leave a fibre client’s 30ms buffer smaller than the new snapshot interval, so it would extrapolate or stall — producing the worst visible stutter at the exact moment the server is least able to absorb it, and the client’s own adaptation would misread it as network jitter and enlarge the buffer, adding input latency without fixing anything. Snapshots carry fidelityFlags, transformTick and maskTick; no client-side buffer adaptation logic is required for a degraded room.
  3. Smooth Linear Interpolation (Lerp): The Babylon.js render thread utilizes the “past” and “future” snapshots from the buffer to perform smooth linear interpolation on every frame (up to 144 FPS). Regardless of how fragmented the physical network packets arrive, 3D object movement retains the buttery-smooth visual fidelity expected of AAA games.

9. Frontend Architecture: The Multi-Profile Presentation Engine

Built as a Client-Side Rendered (CSR) Progressive Web App (PWA), the frontend architecture embraces a high-performance dual-track framework strategy: SolidJS + Tailwind CSS for the Main UI and Lit (Web Components) for the UGC Workshop. This achieves absolute physical decoupling between the UI layer and the WebGPU/WebGL canvas.

9.1 UI Triage Pipeline & SAB Zero-Copy Bridge

To solve the challenge of high-performance synchronization between the 3D Canvas (Web Worker) and DOM UI (Main Thread), while avoiding tearing, ghost coordinates, and performance bottlenecks inherent to raw SharedArrayBuffer (SAB) development, the system implements a strict UI Triage Pipeline and graceful degradation mechanics. All SAB operations are deeply encapsulated by the @descent-vtt/sdk (frontend NPM package), ensuring safe utilization by community developers.

  1. World-Space UI: WebGPU Thin Instances (In-Canvas Rendering)
    • Scenario: High-frequency, massive-quantity tracking UI (e.g., 500 goblin HP bars, nameplates).
    • Implementation: HTML DOM is abandoned. UI elements are baked into a dynamic Canvas2D Texture Atlas, and drawn inside the 3D space using WebGPU Thin Instances in a single Draw Call for tens of thousands of elements.
    • Defense: Depth Write (Alpha Testing) offloads depth sorting to GPU hardware, so per-element CPU cost is eliminated at draw time — the honest scope of that claim. Atlas maintenance is not free and is budgeted: re-rasterising changed cells is CPU work in the streaming/render worker, uploads use dirty-rect copyExternalImageToTexture rather than whole-atlas re-upload, and cells are re-rasterised at most once per frame (an AoE changing 200 HP values coalesces into one dirty-rect batch instead of 200 uploads).
    • Text & Non-Latin Scripts: MSDF (Multi-channel Signed Distance Fields) prevents blurring on zoom for the enumerable glyph set (digits, status glyphs, Latin). It is not viable for CJK: the platform ships zh-TW and player-authored names draw from thousands of code points, so a fixed MSDF atlas produces tofu boxes and runtime MSDF generation is prohibitively expensive. World-space CJK text therefore uses distance-tiered rendering: near range renders crisp Canvas2D-rasterised labels re-rasterised on zoom-step change; mid range uses a cached raster mip; far range collapses to icon + numeric only. Correct rendering of a player’s own name is not a feature that may be sacrificed to a rendering strategy.
  2. Discrete UI Events: SPSC Ring Buffer
    • Scenario: Discrete presentation events (e.g., critical-hit pop-up text, hit sparks, floating numbers).
    • Implementation: A lock-free Single-Producer/Single-Consumer queue built on SAB. The producer is the Render Worker’s CPU-side code, never the GPU: a compute shader cannot write into a SharedArrayBuffer and cannot execute JavaScript Atomics, so any GPU-derived value must first return via an asynchronous mapAsync readback (1–2 frames) and be copied in by the worker. The worker encodes events as pure numeric flags; SolidJS consumes them to generate DOM animations. The “Visual-Sync Queue” that aligns an event with its visual impact frame is therefore a CPU-side scheduling queue keyed to the presentation frame, and its alignment accuracy is bounded by that readback latency.
    • Defense: 2MB pre-allocated SAB and 64-byte cache-line padding to eliminate False Sharing stutter.
    • Bounded Means a Drop Policy Exists (ADR-025): a bounded ring cannot prevent overflow, only delay it, so the policy is explicit rather than emergent: the ring is lossy-by-contract and carries presentation events only, on overflow it drops oldest and increments a RingOverflow counter surfaced in telemetry and to QA, and the consumer detects the gap via a sequence number. Anything that must not be missed — death, incapacitation, condition changes, turn transitions — is not eligible for this channel; it travels the authoritative sequenced path (§8.2) where a gap is detectable and re-requestable. A “must never be missed” guarantee layered on a lossy buffer is how a dead character keeps showing as alive.
  3. Continuous Coordinate Tracking: Triple Buffering Pointer Swap
    • Scenario: Extremely rare, highly complex high-frequency DOM panels that must track 3D models (e.g., spell configuration panels).
    • Implementation: Borrowing from AAA game engines, allocates Front/Back/Next memory blocks in SAB. On commit, the Render Worker publishes the new block index via Atomics.store/Atomics.exchange on a control word, and the Main Thread latches it lock-free. Both sides participate in the protocol — the writer publishes only after its block is fully written, and the reader claims a block for the duration of a frame — because a single-sided “swap” leaves the reader able to latch a half-written block. The GPU is not a participant: it cannot execute Atomics and cannot write SAB (§9.2).
    • Defense: Acknowledges a 1-frame (7~16ms) physical latency but guarantees zero tearing. The SDK forcibly converts all coordinates to transform: translate3d, elevating the element to the GPU Compositor Layer to avoid Layout Thrashing.

Graceful Degradation Strategy: If the player’s device is outdated or restricted, pipelines automatically degrade:

  • Transport Degradation: If SAB is disabled by the browser, the engine seamlessly falls back to standard Worker.postMessage, trading minor GC overhead for absolute stability.
  • Rendering Degradation (Potato Mode): Upon detecting low FPS, the system abandons “Visual-Sync Timelines” (e.g., waiting 400ms for a fireball) to instantly fire events; aggregates and throttles UI events on the frontend; and activates a “Hide-on-Move” strategy, where complex DOM panels are set to opacity: 0 while moving and only shown when stationary, rescuing low-end mobile devices.

Where SAB is actually used, after ADR-052. The three pipelines above are its only consumers, and all three are Render Worker → Main Thread: the SPSC presentation ring (pipeline 2) and the triple-buffer control region (pipeline 3), with pipeline 1 living entirely inside the canvas. The shared WASM arena that Guardrail 3 was written for no longer exists — once client visibility became presentation-only (ADR-034), the geometry crate’s remaining work was low-frequency, small, and request/response shaped, so it moved to a single worker with private linear memory and transferable I/O. Two consequences follow and are stated here because they change the architecture’s risk profile:

  • Cross-origin isolation gates no capability. Its absence relaxes the frame-skew budget by one frame on two channels and nothing else (§9.5 Guardrail 7). Prediction, geometry, plugins, streaming and video are all unaffected.
  • There is one WASM artefact, not two. The +atomics build requirement is gone, which also removes the second parity target: a binary built for shared memory cannot be instantiated with non-shared memory, so keeping the arena would have meant maintaining and parity-testing two builds of a crate whose entire purpose is that there is only one implementation.

9.2 Heterogeneous Compute Slicing (WebGPU + WASM)

  • Authority Rule First (ADR-017): Visibility is a security property owned by the server (§5.4). Nothing in this section may be the source of truth for what a player is permitted to see. Client-side geometry exists to (a) predict the server result for input responsiveness and (b) present it beautifully — never to decide it. Entities the server has not disclosed are absent from the client’s data set entirely; they are never present-but-culled, because a culling shader is one patched line away from being disabled.
  • GPU Vision Slicing (WebGPU Compute — Presentation Only): WGSL compute passes (Jump Flooding to generate Signed Distance Fields in VRAM) upsample and antialias the authoritative mask into smooth, high-resolution fog and lighting for hundreds of dynamic light sources. This is a presentation filter over data the CPU already holds. The physical constraints this design respects: a compute shader cannot write into a SharedArrayBuffer, cannot execute JavaScript Atomics, and any GPU→CPU return requires an asynchronous mapAsync readback costing 1–2 frames plus a copy into shared memory. Consequently no game logic, event trigger, targeting decision, or visibility decision may consume a GPU result, and the GPU is never a producer for the SAB pipelines in §9.1.
  • CPU Logic Slicing (Descent.Geometry / WASM): the client runs the same Descent.Geometry crate revision the server runs (§5.3), compiled to WebAssembly with fixed-point arithmetic, in a single background worker with its own private linear memory (ADR-052). Its scope after ADR-034:
    • In scope: own-entity movement prediction and collision preview over geometry the acting client has been disclosed; A* path preview within that disclosed region; rule pre-validation; preparing the authoritative mask for GPU upsampling.
    • Out of scope: producing LOS or FOW. Both are server-owned (§5.4.1), and the client’s copy is a presentation of the authoritative mask.
    • Why “bit-exact therefore convergent” does not extend to visibility. Identical code over identical inputs converges; the platform guarantees identical inputs only where the client legitimately holds the whole input. It deliberately does not for visibility, so a client-side mask would differ wherever hidden geometry exists — and the correction would be a repeatable side channel, letting a player map an unrevealed secret door to metre precision by watching fog snap back. That is why the fix is a scope restriction rather than a better algorithm: no amount of determinism helps when the two hosts are being asked different questions.
    • Prediction/authority divergence is applied on a fixed cadence with uniform visual treatment, so the timing of a correction cannot distinguish “something is there” from “nothing is there”.
    • Interface: inputs and results cross by postMessage with transferable ArrayBuffers (a move, not a copy); the crate’s navigation structures stay resident in its own memory across calls. The Render Worker never blocks on it — it reads the newest completed result from a sequence-numbered double buffer, because an Atomics.wait on the geometry path would stall the frame loop while DOM stayed smooth, which is the same misattributed symptom §9.6.1 warns about for networking.
  • Hardware Fallback Strategy (Profile B/C): If a device lacks WebGPU Compute (falling back to WebGL2), only the visual upsampling stage is replaced by a coarser WebGL2 blur/stencil path. The mask itself is unchanged, because it was never computed on the GPU. Profile A and Profile B players therefore reveal the same cells and differ only in fog-edge softness, within the tolerance asserted by the parity corpus (§5.3) — as opposed to two independent geometry implementations giving two players different tactical information about the same wall.
  • Presentation cadence is a design constraint on the fog edge. Because the mask is authoritative and pipelined, it advances at the Focus tier of §5.2’s mask cadence ladder, with ADR-033’s one-tick collection lag on top of that interval — not at frame rate. The presentation layer must therefore animate the boundary between mask updates rather than snapping to each one; a fog edge that steps five times a second reads as a bug even though the underlying data is correct. This constraint tightens considerably in WebXR at 90Hz per eye, where the Focus interval plus one tick is about twenty-two frames of staleness on a head-tracked display — XR fog presentation needs its own stated tolerance and its own cell in the Guardrail 7 matrix rather than an assumption that Profile A behaviour carries over. Corrected 2026-08-02: this paragraph previously read “20Hz with a one-tick lag” and “three to four frames”, which predated the cadence ladder that Q-012’s reduction made enforceable; the Focus tier is four ticks, not one, so the staleness it has to hide is roughly five times what was written here. The one-tick lag is not removed by that correction — it is added to the tier interval, and dropping it would understate the budget the presentation layer is being asked to cover.

9.3 Yjs CRDT Dual-Track Collaboration & Offline Hydration (Collaboration Boundary)

To provide a seamless, Google Docs-like multiplayer collaborative editing experience (e.g., NPC journals, tactical whiteboard drawing) under a strict CQRS authoritative architecture, the system implements a Dual-Track CQRS & Yjs CRDT architecture, drawing absolute boundaries:

  • Entity Segregation & UUIDv7 Linkage: Authoritative game entities (e.g., Actor) are governed by CQRS. If an entity contains collaborative long-form text (e.g., backstory), the database stores only a globally unique relational ID (e.g., BackstoryDocId). This ID is strictly generated using UUIDv7, combining a millisecond timestamp with cryptographic randomness, mathematically eliminating any possibility of ID collisions or editing cross-talk.
  • Network Transport Physical Isolation:
    • Authoritative Logic: Routed via SignalR to Orleans for CQRS validation.
    • Yjs Collaboration: Bypasses Orleans entirely, routed peer-to-peer via LiveKit SFU (WebRTC DataChannels) for real-time synchronization.
  • Read/Write Separation Without Timeline Coupling: Yjs binary updates (Blobs) are stored in independent, branch-agnostic tables (§7.3). Upon collaboration completion, backend background jobs flatten the Blob into a plaintext string for pg_trgm full-text search. That text is not projected into the branch-dependent JSONB character sheet: the read model stores only the BackstoryDocId, and the API layer resolves the prose at query time and labels it timeline-independent in the UI. Projecting collaborative prose into an event-sourced read model would otherwise fabricate states that never existed after an Undo or a fork (a current backstory stitched onto a pre-fork character sheet), and would orphan documents whose owning entity exists only on an abandoned branch. This preserves zero-conflict real-time editing and instantaneous loading while keeping the CQRS timeline internally consistent.
  • Offline Hydration: When players edit notes offline, the Yjs document is persisted locally to IndexedDB by a Yjs persistence provider (y-indexeddb), which is a peer of the network provider rather than a replacement for it. On reconnection the Yjs sync protocol itself performs the differential synchronization: each side exchanges a state vector, and each sends back only the updates the other is missing. Merging without overwrites is a property of the CRDT, not of any transport or cache above it — which is why this survives the SFU-to-relay fallback (ADR-026) unchanged, and why the erasure rule in ADR-080 has to be expressed as a CRDT delete rather than a blob mutation.
    • Corrected 2026-08-06. This bullet previously said the frontend “utilizes TanStack Query to perform differential synchronization”, and that was never true rather than having become stale. TanStack Query is an async server-state cache for request/response data; its offline story is a paused-mutation queue that replays HTTP mutations on reconnect. That is a different mechanism at a different layer, it cannot merge CRDT updates, and nothing in the library ever claimed to. The reason it is worth recording rather than quietly deleting is the shape of the error: an outcome was asserted here while the mechanism that produces it lives somewhere else, and nobody owned the join — recorded failure mode #2, arriving in the form where the named component is not merely the wrong owner but incapable of the work. The correction is also load-bearing for two other decisions: if this path were a query cache, ADR-080’s item-granular erasure would have nowhere to apply and ADR-026’s relay would have to re-implement merge semantics.
    • Where TanStack Query does belong is stated so it is not re-proposed here: the low-frequency HTTP surfaces — catalogue, library, account — which are ordinary server state. The offline Intent command queue is a third thing again, owned by the SDK (§12.2.1) under ADR-011’s fail-closed whitelist and ADR-051’s eligibility rules, and it is not a query cache either. Three offline mechanisms, three owners; conflating any two of them is how one of them ends up unimplemented.
  • Mandatory Server Relay Fallback (ADR-026): Yjs carries user-authored content, so it may never depend on a single transport. On networks where WebRTC is unavailable end-to-end — some corporate and campus networks block UDP and DPI-filter TURN over 443 — a P2P-only design leaves a player whose SignalR session is perfectly healthy with a blank whiteboard, empty shared notes, and, critically, local CRDT updates that never merge and are silently lost when the cache is cleared, because “upon reconnection” never arrives. Yjs updates therefore have a persistent relay path over the authoritative SignalR channel (batched, written to the same branch-agnostic blob tables), used automatically when SFU connectivity fails or degrades. The SFU remains the preferred low-latency path; it is not the only one. Collaboration state always has a durable server-side home.
  • Erasing One Author From a Shared Document (ADR-080): the erasable unit is the Yjs item, not the document — every item has exactly one author, so a co-authored notebook loses the subject’s items and keeps everyone else’s, while a document authored entirely by the subject is deleted along with its DocId reference. The erasure must be a CRDT delete operation, never an out-of-band mutation of the stored blob, and must travel the relay as well as the SFU: an offline client re-merges on reconnection under the differential synchronisation above, so a non-CRDT deletion is silently undone by the first client to come back, and skipping the relay would omit exactly the participants hardest to reach again. The flattened pg_trgm search text is purged in the same job — erasing the blob and leaving the flattened row would leave a derived artefact holding what its source no longer does. After convergence the persisted blob is re-encoded so the erased bytes are not recoverable from it; because that depends on the Yjs version’s gc behaviour rather than on anything this document controls, it is a CI assertion over the re-encoded blob rather than an assumption. Two limits are real and stated: a replica that never reconnects never applies the delete, and a co-author who quoted the erased text authored that quote themselves, so erasure does not reach it.
  • Leaving a Table Is Not Erasing Yourself (ADR-085). Departure and erasure are two verbs and must never become one operation: conflated, either leaving a group destroys a player’s year of contributions to a shared notebook, or removing a disruptive participant is unavailable because it would look like deleting their data. Both failure modes come from having one verb. A departed participant’s authored content therefore remains unless they separately request erasure. This document does not define who may remove whom, whether removal is reversible, or what a removed participant retains — those are product decisions and inventing them here would read as decisions somebody made. What is architectural, and is decided now because it is the part that outlives a session, is that revocation must reach the ADR-026 document-write capability token (scoped to RoomId + a DocId set and otherwise valid until expiry) and export entitlement (ADR-022 scopes an archive to a requesting identity and nothing re-checks that the identity still participates). Everything else in the design re-derives authority per request from the RoomGrain; these two are the paths where a stale authorisation is not obvious.
    • The relay needs a host, an authorization model, and a rate limit — none of which followed automatically from mandating it. (1) Host: a dedicated Hub endpoint writes directly to the TimelineIndependent-scoped blob tables (§7.3), bypassing RoomGrain entirely, which is legitimate precisely because Yjs is non-authoritative and branch-agnostic and needs no command validation. Routing it through the Grain instead would put six players’ pen strokes — 120–360 updates/sec on the restricted networks this fallback exists for — into the mailbox §4.4 protects, re-creating the load §7.1 moved to WebRTC in the first place. (2) Authorization: bypassing the Grain also bypasses the permission check the Grain would have performed, so the connection obtains a document-write capability token from the RoomGrain once (scoped to RoomId + a DocId set, with an expiry) and the relay thereafter validates the token rather than calling the Grain per update. Authorize once, relay many. (3) Rate: per-participant coalescing windows, a per-room aggregate ceiling, and a payload size cap. (4) Observability: the active transport mode (SFU vs relay) is attached to the diagnostic envelope (§10.4) — without it, “only this one corporate team stutters” is an unattributable ticket, and a degraded path nobody can see is a degraded path nobody will fix.

9.4 Shader Compilation & Spatial Audio Integration

  • Dual-Target Shader Cross-Compilation: Custom visual pipelines (e.g., Sanity-Linked Shaders) cannot share raw shader code across WebGPU and WebGL2. We implement Dual-Target Shader Cross-Compilation using Node Material architectures (or automated WGSL-to-GLSL transpilation pipelines) to maximise how much of a pipeline survives the Profile A → B transition.
    • The goal is semantic parity, not visual parity (ADR-061). Visual parity “across Profile A/B and Profile C” — as this bullet previously claimed — is impossible by construction: Profile C is defined by its asset tier and draws baked top-down tiles (ADR-005), so there is no 3D scene to shade at all. It also contradicts §2.2’s rule that an unverified feature is disabled rather than degraded. Each presentation effect therefore declares an equivalent expression per profile, and per-effect × per-profile behaviour is a blank-blocks-release row in the Guardrail 7 matrix. This is a rules problem before it is a rendering problem: a Keeper narrating “you all feel the world twist” needs every player to have received something, and the tablet player currently receives nothing.
  • WebRTC Spatial Audio & Mesh Texture Linkage: The LiveKit SFU natively preserves individual, isolated audio tracks for every player (unlike MCU mixed audio). These independent tracks are fed into the Babylon.js 3D Spatial Audio API (PannerNode) for client-side distance attenuation and 3D panning. Player webcam streams can be dynamically rendered as Video Textures on 3D Token Meshes or UI comms panels.
    • The panner’s only permitted input is the viewer’s disclosed set (ADR-167 clause 3). This bullet previously read “based on avatar coordinates”, which is the specification F-R40-01 found unscoped: it does not say whose coordinates, and the plausible reading — attenuate by distance to the speaker — requires a position the viewer may not hold. A client may never pan on a last-known position or on §5.1.1’s advisory position uplink, because a continuous attenuation inverts to a distance and ADR-091 clause 3 rules exactly that out.
    • Where the speaker is not disclosed, the voice is flat rather than muffled, and the inversion is deliberate: muffling is the encoding. A flat voice states nothing about where its speaker is; a muffled one states a distance. Atmospheric occlusion of a concealed speaker is an ADR-091 event — server-computed, pre-attenuated at the aperture, quantised to Q-072 — and is not available as a client-side filter.
    • Not available inside a Discord Activity at all (ADR-167 clause 1), which has no WebRTC and no per-user voice control. This is a property of that surface, not a degradation of this one.
  • WebCodecs API & GPU-Resident Video Textures: To support 4K animated video maps (e.g., ocean waves, lava) without blocking the main thread, the frontend discards traditional HTML5 <video> tags. MP4/WebM streams are hardware-decoded in a background worker via the WebCodecs API, and each VideoFrame is imported as a WebGPU external texture for GPU-resident sampling without a CPU pixel copy. Four constraints are designed for rather than wished away:
    1. WebCodecs does not demux. VideoDecoder consumes EncodedVideoChunks; container parsing is the application’s job, so an MP4/WebM demuxer (WASM) is an explicit, budgeted component of the media pipeline — not an implicit capability of the API.
    2. External textures are per-task. An imported external texture expires at the end of the task in which it was created, so it is re-imported every frame; bind groups are rebuilt accordingly rather than cached across frames.
    3. Hardware decode sessions are finite. GPUs support a small number of concurrent hardware decode sessions; beyond it the browser falls back to software decode and the “0% CPU” property inverts completely. Simultaneous animated maps are therefore capped (default 2 at 4K, 4 below 1080p) with the remainder frozen on their last frame, and VideoDecoder.isConfigSupported() gates the codec/resolution before any commitment.
    4. Fallback path. On WebGL2, or where hardware decode is unavailable, animated maps degrade explicitly to a lower-resolution decode with per-frame texImage2D upload at a capped frame rate, or to a static poster frame on Profile C — a defined degradation, not an unstated dependency (§9.5 Guardrail 7).
  • No Audio or Video Is Ever Persisted (ADR-081). This is already true by construction — §7.1 puts this traffic on WebRTC with zero database writes, and ADR-022 archives are built from events, so media has no path to storage. It is stated here as an invariant in its own right because it was previously only a side effect of a latency argument, and a property recorded only as a consequence of a performance decision does not survive the next performance decision. The enforcement point is the SFU deployment, not the .NET codebase: media never enters the .NET process, so an ArchitectureTests rule would pass forever while testing nothing. LiveKit’s recording and egress capabilities are not deployed and their absence is asserted in the infrastructure definition. Session recording is a legitimate future feature and requires an ADR superseding ADR-081 — the operative effect of this clause is to make that a decision rather than a configuration flag. The invariant is unconditional across every supported deployment (ADR-108). It previously carried an explicit exemption for the Self-Hosted profile, where the SFU sat in an operator’s hands and this document could say nothing about it; that profile no longer exists, so the exemption is deleted rather than reworded. What has not changed, and must not be read as widened by that deletion, is that it does not cover the participants. A GM or player running local screen or audio capture is entirely outside this guarantee, the platform cannot detect it, and voice plus webcam is the most sensitive stream in the system. The boundary is stated here, next to the guarantee, because a narrow guarantee left unqualified reads as a broad one.
  • AudioWorklet DSP Architecture: Heavy Digital Signal Processing (DSP), such as Sanity-Linked Audio Distortion (pitch shift, deep-sea echo), must strictly execute within an AudioWorkletGlobalScope. This offloads all microphone manipulation to a dedicated background audio thread, preventing voice glitching or stuttering during heavy SolidJS Main Thread UI renders.

9.5 Micro-Architectural Defense & Edge-Case Protocols (The 7 Guardrails)

To guarantee esports-grade stability, zero-trust security, and zero-frame latency desync within our Vite build system, all frontend engineering must strictly adhere to the following 7 micro-architectural defensive mandates. (There were five when this section was written; Guardrails 6 and 7 were added by audit, and the count in the heading had not followed — the smallest possible instance of the drift ADR-045 exists to prevent, left visible here rather than silently corrected.)

Guardrail 1: WebGPU Device Loss & Synchronous Rehydration (DeviceLossStateMachine)

Browser GPU contexts are inherently volatile. If device.lost resolves, the OffscreenCanvas Worker must immediately halt the SharedArrayBuffer (SAB) ring queue and post a GPU_CONTEXT_LOST message to pause the Main Thread bridge.

  • Recovery Rule: The worker requests a new adapter with bounded exponential backoff, not an unbounded loop. Recovery then rebuilds GPU state from the Main Memory ECS (TypedArrays) — and is explicitly asynchronous, progress-reporting, and re-synchronising, because the naive “synchronous rehydration” is impossible in this architecture and would fail three ways:
    1. Textures are not in JS memory by design (§6.1 streams them OPFS→GPU to keep the heap near zero), so recovery must re-read, re-decrypt, and re-transcode the resident working set — seconds of I/O for a large map, not a synchronous loop. The user sees a progress indicator, not a frozen canvas.
    2. The UI must not be held hostage. Pausing the SAB bridge freezes DOM UI too, so on GPU_CONTEXT_LOST the app enters an explicit Recovering state that keeps chat, sheets, and networking interactive while the canvas is dark.
      • This is only achievable because of the topology in ADR-053, and was not achievable before it. If every piece of authoritative state reaches the DOM through the Render Worker’s ECS and its frame loop — which was the previous arrangement — then a worker busy re-reading, re-decrypting and re-transcoding its texture working set publishes nothing, and the Main Thread latches the values it held at the moment of loss. The result is worse than a frozen canvas: the UI stays clickable and scrollable while displaying stale state, so a Keeper adjudicates from an HP total that is minutes old. The Network Worker therefore publishes DOM-facing authoritative state on its own channel, independent of the Render Worker entirely.
    3. World state has advanced. The server kept ticking throughout. Recovery therefore ends by requesting a per-viewer Full Snapshot (§8.2) before resuming delta application; resuming with a pre-loss baseline would leave every token at a stale position with no correction path.
  • Terminal Loss Rule: if adapter acquisition fails past the backoff ceiling, or the browser blocklists WebGPU for the origin after repeated resets, the client falls back to the Profile B WebGL2 backend and, failing that, Profile C — it does not retry forever behind a “restoring” spinner while a working renderer sits unused.
    • Refinement (2026-08-08): the fallback is one-way within the session, gated on asset availability, and restored only on request. Four rules, each closing a way the obvious implementation goes wrong:
      1. The ladder is not skipped. Terminal loss goes to B before C. Dropping straight to a 2D mode leaves a working 3D renderer unused, which is the failure the sentence above already names — and it is the intuitive design, which is why it is refused explicitly.
      2. Profile C is entered only when the map can be rendered in it. ADR-005 requires every bundle to declare a top-down 2D bake and makes bundles without one explicitly unavailable in Profile C, rendering as labelled footprint placeholders. The manifest’s profileCSupported flag is therefore a precondition of this rung, not a detail — an ungated fallback drops a player into a map of placeholders at the exact moment they are already dealing with a failure. Where it is false, the session holds at Profile B, or reports terminal-unavailable rather than degrading into an unusable view.
      3. Falling back is not “switching to an orthographic camera”, and the distinction is load-bearing. §5.1 records that an ortho camera “changes framing, not cost — it still submits the same meshes, materials, and texture working set”, and that Profile C is defined by its asset tier. Since device loss on large maps is commonly VRAM exhaustion (§6.1), a camera change over an unchanged working set would lose the device again. The rung must swap the asset tier or it is not a fallback.
      4. One-way within the session, with a manual restore. No timer re-attempts 3D after a terminal fallback; a user-visible “Attempt 3D restore” control is the only path back, which expresses the anti-crash-loop property as an intent rather than a heuristic and cannot itself oscillate. The transition carries a neutral progress treatment — deliberately not a fog-of-war motif, because the fog is the game’s own visual vocabulary for a Keeper revealing terrain (§5.3, ADR-034) and reusing it for a hardware failure makes the two indistinguishable to the player.
    • Recorded as a refinement rather than an ADR, on §11’s own criterion: it contradicts no decision and rejects no re-proposable alternative — it makes the existing ladder’s preconditions explicit. The profileCSupported gate is ADR-005’s registration rule applied at run time, not a new one.

Guardrail 2: SolidJS Zero-VDOM Native Synchronization

Traditional Virtual DOMs (as seen in frameworks like React/Vue) cause severe Layout Thrashing and a 1-2 frame visual desync (rubber-banding) against the WebGPU worker at 144 FPS. SolidJS naturally solves this.

  • 0-VDOM Rule: SolidJS reactive bindings mutate element matrix attributes (style.transform) directly, with no diffing layer between a committed frame value and the DOM write. This removes VDOM reconciliation and layout thrashing from the critical path — which is the real benefit, and the reason SolidJS is the correct choice here.
  • A Latch Is Required (there is no reactivity over shared memory): a worker’s write into a SharedArrayBuffer fires no notification and cannot trigger a signal. The Main Thread therefore runs exactly one requestAnimationFrame latch (or Atomics.waitAsync where appropriate) that reads the published blocks and writes the current values into signals once per frame. This is a single, owned, architecturally-blessed latch rather than the ad-hoc per-component rAF loops it replaces — but it exists, and a design that assumes signals fire themselves from SAB writes produces UI that never updates at all.
  • One latch, two sources (ADR-053). The latch reads from both publication channels: the Render Worker’s triple-buffer block (§9.1.3, derived screen-space values for canvas-tracking panels) and the Network Worker’s DOM-facing authoritative state (chat, sheet fields, turn order, notifications, DurableSeq, reconciliation outcomes, lease events, plugin budget events). The rule that made this guardrail valuable is “exactly one latch”, not “exactly one source” — and confining state to a single source is what made Guardrail 1’s Recovering state unimplementable.
    • Ownership rule for values that appear in both worlds. A value rendered both in the DOM and in world space — Hit Points being the obvious case, appearing on a sheet and in a token’s floating bar — belongs to the Network Worker’s channel, with the Render Worker consuming it as a peer rather than relaying it. Otherwise the same number reaches the sheet and the atlas by two independent paths and can disagree between them. New authoritative fields declare their channel at review time; a misfiled one is discovered as “this value freezes during GPU recovery”, which is expensive to attribute.
  • Bounded Skew, Not Zero Skew: the OffscreenCanvas is presented by the worker’s frame loop while DOM is composited by the Main Thread, so a 1-frame (7–16ms) skew is physical — as §9.1.3 already states. The guarantee offered is bounded and stable skew (never accumulating, never tearing), not frame-locked equality. UI that genuinely cannot tolerate one frame of skew belongs inside the canvas (§9.1.1), which is why world-space UI is rendered there.
[Web Worker: GPU Render] ---> SharedArrayBuffer ---> [Main Thread: SolidJS Reactive Signal] ---> DOM `transform`

Guardrail 3: The Geometry Worker Boundary (and why the shared arena was retired)

Rewritten under ADR-052. This guardrail previously specified a zero-copy shared memory arena for client FOW and A*, and its technical reasoning was correct for the scope it was written against. ADR-034 removed that scope: client LOS/FOW became presentation over the server’s authoritative mask, so the crate’s remaining client-side work is own-move A*/collision preview — low-frequency, small, and request/response shaped. The arena’s costs were then paying for a workload that no longer exists, so it is retired. The original reasoning is preserved below because it remains true and will be needed again if the scope ever returns.

  • What the crate gets now: one worker, its own private linear memory, navigation structures resident across calls, inputs and results crossing by postMessage with transferable ArrayBuffers — a move rather than a copy. Not a pool: after ADR-034 there is no per-viewer client geometry left to parallelise.
  • The Render Worker never blocks on it. No Atomics.wait anywhere on the geometry path; results are read from a sequence-numbered double buffer, newest-completed-wins, and a long A* is asynchronous with the drag preview showing the last completed path. A blocking wait would stall the frame loop — 3D visibly stuttering while DOM stays perfectly smooth, which is the same misattributed symptom §9.6.1 documents for networking.
  • The GC claim, rescoped honestly: the geometry path now performs one transfer per call. That is not a copy, but neither is it the original “serialize nothing” — and pretending otherwise would leave the next reader unable to tell which claim they are allowed to rely on.
  • What retiring the arena bought: no +atomics,+bulk-memory,+mutable-globals build, therefore one WASM artefact instead of two (an +atomics binary cannot be instantiated with non-shared memory, so the fallback path was a second build, not a runtime switch), no per-thread stack contract for multiple instances sharing one address space, no Atomics.wait hazard, and no hard dependency on cross-origin isolation anywhere in the geometry path.
  • Preserved for the record — why a shared arena, if ever needed again, must be the module’s own memory. WebAssembly can only address its own linear memory. A separately allocated SharedArrayBuffer cannot be handed to Rust as addressable memory, and passing its byteOffset as a “pointer” makes Rust read its own heap at that address, yielding garbage geometry or a trap. The arena must therefore be an imported new WebAssembly.Memory({ shared: true }), with JS writing typed-array views at crate-published offsets. The fix that would otherwise be discovered in debugging — “just copy it into WASM memory first” — reintroduces precisely the copy such an arena exists to remove.
  • Reversal condition: if a future feature genuinely needs high-frequency (≥30Hz), large, bidirectional shared geometry state on the client, the arena returns and brings back the +atomics build, the second artefact, and the cross-origin isolation dependency with it. The decision is reversible but not free to reverse, which is why the condition is written down.

Guardrail 4: CQRS Command Queue & Server Reconciliation (Optimistic UI)

Generic Local-First database sync engines (e.g., PowerSync) bypass the authoritative backend validation logic, posing a severe anti-cheat risk.

  • Command Queue Rule: When offline or experiencing latency, the frontend utilizes Optimistic UI to update the local SolidJS state instantly, while storing the action as an Intent (Command) in an IndexedDB queue.
  • Offline Eligibility Is a Fail-Closed Whitelist, and Spatial Movement Is Not On It (ADR-051). §5.1.1 requires a lease before an entity may be predicted, and a client that cannot acquire one falls back to server-driven movement — which offline means falling back to nothing. The two rules gave opposite answers for the commonest offline gesture, so eligibility is now declared per Intent type and defaults to ineligible:
    • Eligible: character-sheet edits, own-inventory reorganisation, notes (already TimelineIndependent), macro authoring, chat.
    • Ineligible, with the UI disabled and a stated reason: anything requiring a lease, a server-issued path, or a die roll. Disabling up front beats accepting the action and rejecting the batch twenty minutes later.
    • Offline spatial intent becomes a local planning layer, not a queued Intent: the player draws intended paths and markers as TimelineIndependent annotations, and on reconnect these are presented for execution rather than auto-submitted. This removes the rebase problem for the highest-risk class entirely — an offline move rebased onto an advanced timeline may target a square that is now occupied, and Guardrail 4’s own Timeline Rule already says rebasing onto an abandoned branch has no defined semantics.
    • Long-lived offline leases were rejected outright and the reason is worth stating, because they are the obvious workaround: a lease held by a disconnected client suppresses that entity’s transform from every snapshot (§5.1.1), so one offline player would freeze an entity to its staleAnchor for the entire room until expiry.
    • The claim this scopes: “Offline-First” applies to authored content and sheet-level state, not to world interaction. For a server-authoritative real-time VTT that is the correct boundary, but it is narrower than the phrase implies and §13 states it accordingly.
  • Server Reconciliation Rule: Upon reconnection, the command queue is flushed to the Orleans backend. If a command is rejected by the authoritative RoomGrain (e.g., an illegal move), the server transmits a Reconciliation Event via FlatBuffers and the frontend rolls the Optimistic UI back to the authoritative state.
  • Causality Rule (a queue is a causal chain, not a set): every queued Intent carries its causal premise (base entity versions and the intents it depends on). Rejecting intent N invalidates every dependent intent after it as one atomic set, presented to the user as a single “these actions could not be applied” review rather than a cascade of independent rollbacks. Rolling back only the rejected intent is unsafe: a rejected move followed by an attack from the vacated square, or a rejected Magic-Point spend followed by the spell it paid for, leaves the client having resolved consequences of a premise the server refused.
  • Timeline Rule: if the room’s timeline advanced past — or forked away from (§7.3) — the base version a queued intent was authored against, the queue is rejected as a batch with an explicit explanation and the user re-acts against current state. Rebasing intents onto an abandoned branch has no defined semantics and must never be attempted silently.
  • Bounded Queue: the offline queue is capped (count and age); on overflow the client refuses further optimistic actions rather than accumulating a divergence too large to reconcile meaningfully.

Guardrail 5: Zero-DOM WASM/QuickJS Sandboxing (Figma Model)

Amended 2026-08-10 by ADR-144 and ADR-146, and the text below is kept rather than rewritten because most of it survived. The guardrail was written before the worker existed and three of its sentences did not survive contact with a browser. What changed:

  • The guest is not QuickJS. It is the same wasm32-unknown-unknown payload the silo runs, on the same three-name ABI, declaring zero imports (ADR-144). QuickJS is C and needs a libc that target does not have; the property being bought is that one artefact runs in both hosts.

  • Clause 1’s interrupt callback does not exist and cannot. A synchronous guest call is not interruptible in a browser, and every mechanism that would make it so requires giving the guest an import. The hard deadline is Worker.terminate() — measured on a spinning guest at a latency ADR-146 records, and stronger than an interrupt because it cannot be caught — and the soft deadline degrades to after-the-fact detection that defers the plugin’s next invocation. Spike S6 is closed by ADR-146; this is the outcome S6 named as its fallback for the soft half and better than it asked for on the hard half.

    Narrowed 2026-08-11 by ADR-165, and the sentence above is kept because what it says about interruption is still true. No mechanism reaches into a running guest without an import — and that is a statement about moving information inward at run time, which is what an import is for. Metering is not in that class. A budget installed before the call, into an exported mutable global the guest already owns, needs no host edge: the guest decrements it at every function entry and loop header and traps on unreachable when it runs out, and the rewritten module still declares zero imports. So the soft ceiling is no longer only after-the-fact — an overrun is now a catchable, attributed refusal inside the invocation that caused it — while Worker.terminate() is retained as the outer backstop on P3, because gas binds only what the rewriter touched and an in-band counter is weaker than a structural boundary. The same meter covers the JavaScript tier without a second mechanism, since the guest is the interpreter. Nothing consumes any of this yet.

  • Clause 6’s prioritisation is satisfied structurally rather than by scheduling. Each plugin gets its own runtime worker and the supervisor never executes creator code, because a priority scheme cannot deschedule a guest that has already started. ADR-040 rejected one-worker-per-plugin on memory grounds; a compiled WebAssembly.Module turns out to be structured-cloneable with shared code, so N runtimes cost N linear memories rather than N interpreters.

  • Clause 5’s worked example is now measured rather than predicted, and it was right. “An accidental O(n²) sweep over 500 tokens … this is hundreds of milliseconds” — the reading is tens of frames’ worth for one plugin’s frame of work, and three orders of magnitude above the same logic in the browser’s own JavaScript engine. The figures are in ADR-146 and no Q-ID is minted for them: they are readings, not budgets, and P5 reserves the registry for a figure profiled against a target. The consequence the guardrail did not draw: the JavaScript tier must not be on the frame path, and a plugin that needs a per-frame budget should be compiled to wasm32 and run on the same ABI beside it.

Everything else below — the budget regime’s shape, the interest-set cap, the attribution rule, the per-frame snapshot, the SAB independence — is unchanged and is what was built.

UGC third-party components introduce severe DoS, CSS leakage, and memory exhaustion risks. Traditional Cross-Origin Iframes provide security but incur massive RAM overhead (booting multiple React/Vue runtimes).

  • Single Engine, Runtime Per Plugin (ADR-040): All community plugins execute within a single dedicated PluginWorker running QuickJS compiled to WebAssembly — a pure ES2020 environment devoid of DOM, window, and fetch, with malicious code trapped in a C-memory boundary. What is single is the engine binary, which is what the rule was always about (avoiding N framework runtimes); each plugin gets its own QuickJS runtime, because a single shared runtime gives plugins no isolation in the time dimension at all.
  • Resource Budgets, Mirroring §4.3. This guardrail’s own motivation names “severe DoS, CSS leakage, and memory exhaustion risks”, and the C-memory boundary answers only memory corruption. Access control and resource control are different properties, and P1 and P2 are peers in who may publish (§6.4) — so P2 gets the matching regime:
    1. Per-invocation soft/hard deadlines enforced by an interrupt callback: soft yields and requeues once, hard aborts the invocation and discards its pending RPC effects.
    2. Per-runtime heap ceiling.
    3. A per-frame aggregate ceiling across all plugins, expressed as a fraction of the frame budget, with overflow deferred FIFO and reported to the author as plugin:deferred.
    4. A rolling token bucket per plugin, defeating “stay under the per-call limit and run every frame”.
    5. Every budget event names the plugin. Without attribution, “the VTT is slow” is an unactionable ticket; per-plugin CPU and heap appear in a user-visible panel and in the diagnostic envelope (§10.4), and a repeat offender can be auto-disabled with notice. The concrete failure this closes: one plugin doing an accidental O(n²) sweep over 500 tokens per frame — QuickJS being an interpreter, this is hundreds of milliseconds — starving every other plugin and the JSON-AST bridge, so the Keeper’s combat tracker freezes with nothing indicating why.
    6. The RPC/UI bridge is prioritised above plugin execution (or moved to its own worker). A hung plugin must not be able to freeze the delivery of other plugins’ descriptors.
    7. Declared interest sets are capped at registration, not silently truncated at run time — consistent with §12.2.2’s principle — because the per-frame snapshot cost below scales with (interest set × plugin count).
    8. Budget values are host-supplied runtime configuration and published as documentation, following §3.1’s rule for the server sandbox; but note that a client-side budget is inherently discoverable by the code it constrains, so it defends against accidental cost, not against a determined author. Its purpose is attribution and containment.
    9. Budgets are device-relative, which authors will not discover on their own machines. A plugin comfortable on a desktop is plugin:deferred on a Profile C tablet, so the SDK dev server (§12.1) must be able to impose a simulated low-end budget.
  • Declarative UI (Server-Driven UI) Rule: Plugins cannot write HTML. They utilize the @descent-vtt/sdk to construct UI logic and emit JSON AST UI Descriptors via RPC. The Main Thread’s SolidJS engine parses this JSON and renders native UI components, ensuring 0% plugin layout thrashing and perfect visual consistency.
  • Low-Latency Read Rule: The platform has no read-only SharedArrayBuffer primitive — a typed-array view carries no access control — and QuickJS/WASM cannot address memory outside its own linear memory in any case. The plugin worker therefore receives a host-published, per-frame immutable snapshot copied into the QuickJS heap (a compact fixed-layout record of the entities the plugin has declared interest in). This preserves the intended property — plugins read fast and cannot corrupt engine state — by construction rather than by an unenforceable promise. State mutations are routed exclusively through the SDK’s CQRS Command Queue.
  • Degradation: where cross-origin isolation is unavailable (§6.1), the plugin path continues unchanged, because the snapshot is a copy rather than a shared mapping. Plugin functionality is therefore independent of SAB availability — see Guardrail 7.

Guardrail 6: Cold Start Edge-Hold

To prevent 503 Bad Gateway errors from reaching the player during a scale-from-zero wake, the frontend network layer (SignalR Client) implements a Cold Start Edge-Hold Strategy.

  • Edge-Hold Rule: When the connection encounters a 503 or Timeout, it must not throw a fatal error. Instead it shows a “Server is awakening” transition and retries in the background with Exponential Backoff until the container is serving.
  • The Budget Is the Whole Chain, Not the JIT (measured, not asserted): ”.NET JIT warm-up” is one term in a sum. The wake path is: ACA scheduling + image pull (if not node-cached) → CoreCLR/JIT warm-up → Neon compute resume → Orleans cluster join including voting out stale membership rows (§4.4) → Garnet connection → grain activation → snapshot read. A realistic end-to-end p95 is therefore 5–20 seconds, not 1–3, and the figure is a tracked SLO measured in production rather than an estimate. Prewarm (a minimum-replica floor during known peak hours) is the lever if that SLO is missed — and it is a cost decision, not a free one.
  • Distinct Wake States (never one spinner for two waits): “container waking” (seconds), “room hydrating from cold storage” (§7.4, tens of seconds), “projection catching up”, and “timeline checkout in progress” (§7.3) are separate, individually-labelled states with progress where progress exists. A single indeterminate animation covering them all leaves the player unable to distinguish “two more seconds” from “this campaign will not open”, which is the difference between patience and a support ticket.
    • “Your client is out of date” is not a wait at all, and Edge-Hold must not render it as one (ADR-054). A handshake refused on protocolVersion (§8.1) is a terminal condition requiring a different user action, yet backoff-and-retry presents it identically to a cold start — so the player watches “server is awakening” forever while support diagnoses a capacity incident that is not happening. The client checks the published minimum-version endpoint before connecting so the update normally happens first; a refusal that still occurs triggers a bounded forced update (flushing offline Intents and unmerged Yjs beforehand), and if the client is already current, a terminal explanatory screen — not a spinner.
  • Schema Migration Is Not a Startup Task: Marten schema application runs as a dedicated deployment step, never on silo boot with AutoCreate enabled in production. N replicas cold-starting concurrently would otherwise race on DDL, producing lock waits or deadlocks and failed startups precisely during the traffic spike that caused the scale-out.

Guardrail 7: The Capability × Consumer Matrix (ADR-027)

A fallback that covers one consumer of a capability is not a fallback. Because the degradation strategies in this document were previously written per-subsystem, one missing capability could silently break three subsystems that never declared a dependency on it. The following matrix is normative: every cell must read Supported, Degraded (defined behaviour), or Unsupported (defined user-visible behaviour) — a blank cell blocks release.

A cell is satisfied by a passing test, not by a sentence (ADR-064). The absent-capability behaviour described in each cell below occurs only when the capability is absent, and it never is on a developer machine or a CI runner. Each cell therefore names the test that forces the condition through §14.6’s capability veto and asserts the stated behaviour; a cell whose test does not exist is treated exactly as a blank cell. This is what upgrades the rule from “somebody wrote down what should happen” to a property of the build.

The matrix became executable on 2026-08-08 (ADR-124), and it reads as blocked. It is declared in apps/vtt-frontend-client/src/platform/guardrail7-matrix.ts, where every row is either the name of the test that demonstrates it or a recorded reason for being blank — exactly one, never both and never neither. Two rows are demonstrated and fourteen are blank, and every blank names the consumer that does not exist rather than saying “not yet”, because that is the thing whose arrival closes the row. The blank set is pinned by a flat ledger asserted for exact equality, so it fails in both directions: a new blank is red, and a row demonstrated without pruning its ledger line is equally red. A blank is never a skipped test — a skip prints as “covered, temporarily off”, which is the appearance ADR-064 exists to remove. The gate blocks a release, not the build.

CapabilityConsumersAbsent-Capability Behaviour
Cross-Origin Isolation / SAB§9.1 pipelines 2–3 only (Render Worker → Main Thread); Guardrail 2 latchBoth channels → postMessage batched per frame; frame-skew budget relaxes by one frame and nothing else changes. The geometry arena is gone (ADR-052), so geometry, prediction, plugins, streaming and video are all unaffected. No capability and no correctness property depends on this row — the honest scope, after the earlier version overstated it as four cascading degradations. Isolation is still protected by §6.1’s route partitioning and CI check. Demonstrated by guardrail7-matrix.test.ts › Guardrail 7 — Cross-Origin Isolation / SAB absent (ADR-124), which forces the condition through the veto and asserts the channel swap, the one-frame relaxation as a delta against a caller-supplied base, that no other capability moves, and that the streaming policy is unchanged.
WebGPURendering; §9.1.1 world-space UI; §9.2 visual upsampling; §9.4 video texturesProfile B WebGL2: ANGLE_instanced_arrays atlases, coarse fog upsampling, per-frame texImage2D video at capped rate. Masks unchanged (server-computed).
WebGPU device loss / blocklistedAll of the aboveGuardrail 1: bounded retry → async rebuild with resync → terminal fallback to Profile B, then C. Chat, sheets and networking stay live throughout, via the Network Worker’s own channel (ADR-053).
OPFS§6.1 streaming + chunk decryption; §9.6.1 streaming worker; §9.6.2 LRUJS-memory loading with Aggressive LOD Throttling; encrypted chunks decrypted per-use in memory and never cached to disk; premium bundles capped to the low-resolution tier. Quota exhaustion mid-session is the same state (§9.6.2). Note that protected bundles additionally require an online key exchange per session (ADR-060), so absent connectivity they are unavailable regardless of OPFS. Demonstrated by guardrail7-matrix.test.ts › Guardrail 7 — OPFS absent (ADR-124): each clause above asserted separately, plus the quota-exhaustion equivalence — a degraded OPFS and an absent one must produce the identical policy, or §9.6.2 acquires a state its own text does not describe.
Hardware video decode / WebCodecs§9.4 animated mapsSoftware decode at reduced resolution and frame rate, capped concurrency; Profile C → static poster frame.
WebRTC / SFU reachable§7.1 ephemeral; §9.3 Yjs; §9.4 voice + spatial audio; §9.6.3 cursors, dice animation, whiteboardYjs → SignalR relay (ADR-026, no data loss). Ephemeral peer data → the entity renders at its labelled staleAnchor with a “being moved by {owner}” affordance (ADR-050); this is position loss, not merely visual polish loss, and the earlier “suppressed with an explicit UI indicator” understated it. Authoritative state unaffected. Dice → deterministic local playback from the server-issued trajectory index (ADR-041). Voice → unavailable with clear notice.
WebTransport / HTTP/3§9.6.8SignalR over WebSockets remains the supported baseline (§9.6.8).
IndexedDB / persistent storageGuardrail 4 offline queue; §9.3 offline YjsThe offline whitelist (ADR-051) is disabled with an explicit notice rather than accepting edits that cannot survive a reload. Spatial actions were never eligible, so nothing additional is lost there.
Worker-scope font rasterisation§9.1.1 world-space CJK labelsWorld-space labels fall back to the Profile C DOM path (§9.6.5), which renders CJK correctly. Note the counter-intuitive consequence worth stating explicitly: the low-end tier is the one that is always correct about text. A subset font (common glyphs first, rare glyphs fetched on demand) covers the startup window in which a multi-megabyte full font would otherwise leave every nameplate blank.
Presentation-effect availability (per effect × per profile)§9.4 Sanity-linked shaders and every other narrative effectEach effect declares an equivalent expression per profile (ADR-061); a blank cell blocks release. This row grows with the effect catalogue and is kept manageable by grouping effects into screen-level / entity-level / audio-level classes.
Yjs transport mode§9.3 collaborationSFU preferred; SignalR relay is a supported, durable path, not a failure state. The active mode is attached to the diagnostic envelope (§10.4) — a degraded path that operators cannot see is a degraded path nobody fixes.
Concurrent hardware decode sessions§9.4 animated mapsAlready capped by §9.4 (default 2 at 4K, 4 below 1080p) with the remainder frozen on their last frame; recorded here because a limit enforced in prose but absent from this matrix is a limit that gets forgotten at release review.
Viewport class & touch-only input§9.7 Profile C role selection; every hover-triggered affordance in the desktop UIResolves C-Tactical (map + docked sheet) or C-Companion (sheet-first, map as locator) per ADR-066. Every hover-only affordance — range preview, tooltip, threat highlight — declares a touch equivalent (selection-triggered, long-press, or persistent); “it appears on hover” is a blank cell. Free camera rotation and double-tap are not offered on C-Companion by decision, not by limitation (§9.7.2).
Application backgrounded / OS throttling§9.7.3 leases; ADR-049 room liveness; Guardrail 4 offline queue; the §9.7.4 situation stripvisibilitychange → hidden releases all held leases immediately rather than waiting for expiry, and the client stops counting as room-activation liveness (ADR-067). A room whose last foregrounded client leaves goes Dormant after Q-051 — tick suspended, keep-alive held a further Q-052 — and never Dormant while work is pending; an unknown foreground signal counts as foregrounded (ADR-072). Return to foreground is a resync, not a reconnect: per-viewer Full Snapshot before delta resumption, identical to Guardrail 1. Backgrounding is never treated as data loss.
Sustained thermal budget§9.7.5; Profile B/C frame budgetsProfile C declares a sustained budget, not a peak one, with an automatic and user-visible reduction step: frame-rate cap, animation suspension, then effect-class downgrade under ADR-061. A silent reduction is indistinguishable from the application simply becoming worse.
Accessibility: screen reader / keyboard-onlysemantic mirror and ARIA announcementsDefined 2026-08-06 by ADR-101…103 and directive D-6.1; the cell is no longer blank but its tests do not exist, so ADR-064 still treats it as blocking. The mirror is built from the disclosed entity set, never by walking the render scene graph — screen-reader DOM is plain text and needs no client modification to read, so a wider source is a worse channel than the culling shader §9.2 rejects. Announcements pass the same per-viewer filter (§8.2) and are scheduled on tick arrival rather than on what changed (ADR-101), because rate, ordering and interrupting silence carry information a containment check cannot see. Publication on both channels is gated by the Network Worker on the older of transformTick and maskTick (ADR-102), so the text path cannot re-open the window the visual path closes. Every presentation effect declares a non-visual expression (ADR-103); “none” is inadmissible. Keyboard-only input is permanently on the server-driven path — correct, one round trip less responsive — and a lease expiry renders as ADR-050’s deliberate return, whose explanation is itself subject to ADR-103 (D-6.1).

Session Capability Report: capability detection runs once at startup, resolves the effective Profile, and is attached to every telemetry event and error report (§10.2). An operator diagnosing a desync must be able to see which cells were active in that session without asking the player.

9.6 Advanced Frontend Pipeline & Resource Management (Day 2 Operations)

To push the SolidJS and WebGPU architecture to the absolute limits of performance and user experience, the following advanced pipelines are integrated to handle edge-case resource exhaustion and sub-50ms interaction goals:

  1. Strict 5-Tier Worker Topology: To keep both the render loop and the DOM out of the I/O path, and to keep DOM-facing state out of the GPU’s failure domain, the frontend enforces a strict execution topology:
    • Main Thread (UI/Input): Exclusively handles SolidJS DOM rendering, raw DOM input interception, and the single frame latch (Guardrail 2). It is not on the network hot path.
    • Network Worker (SignalR / Transport) — the single arrival authority (ADR-053): Owns the connection and protocol decode. Networking was previously assigned to the Main Thread, which put a 20Hz snapshot ingest behind every DOM update — a 200ms UI stall (a large sheet, a GC) would drain the interpolation buffer and freeze the 3D world while the UI looked healthy, misdirecting diagnosis at the GPU. The snapshot path must not depend on UI idleness.
      • It fans out to two consumers, and this is the fifth edge. DOM-facing authoritative state (chat, sheet fields, turn order, notifications, DurableSeq, reconciliation outcomes, lease events, plugin budget events) goes straight to the Main Thread’s latch; world state goes to the Render Worker’s ECS. Routing everything through the Render Worker — the previous arrangement — means that a worker busy rebuilding GPU state after device loss publishes nothing, so the DOM keeps displaying stale values while remaining fully interactive. That is what made Guardrail 1’s Recovering state unimplementable, and it is a worse failure than a dark canvas because a Keeper cannot tell that the numbers have stopped moving.
      • A value that appears in both worlds belongs to this channel, with the Render Worker consuming it as a peer: Hit Points reach both the sheet and the token’s floating bar from one source, so the two can never disagree.
    • Render Worker (GPU): Monopolizes the OffscreenCanvas, running the Babylon.js render loop and the Frontend ECS. It performs no blocking I/O and no bulk decryption.
    • Streaming Worker (OPFS I/O, crypto, transcode): Owns the OPFS FileSystemSyncAccessHandle, chunk decryption, and Basis transcoding, handing finished GPU-ready payloads to the Render Worker. FileSystemSyncAccessHandle.read() blocks, and a 4MB read plus AES-GCM decrypt plus 4K transcode on the render thread stalls the frame loop outright — panning across six chunks produced a multi-hundred-millisecond freeze. Chunks are 512KB (§6.1) and each unit of work is sized to stay inside a frame budget; when streaming cannot keep up, the visible result is a low-LOD placeholder, never a stalled frame.
    • Geometry Worker (Descent.Geometry / WASM) — one worker, private memory (ADR-052): runs the same crate revision the server runs (§5.3) in its own linear memory, exchanging inputs and results with the Render Worker by transferable ArrayBuffer. It owns own-entity movement prediction, collision and A* preview within the caller’s disclosed region, and rule pre-validation. It does not produce LOS or FOW: those are server-owned, and the Render Worker’s WebGPU compute passes upsample the authoritative mask rather than computing one (§9.2, ADR-034).
      • Not a pool, and not Atomics.wait. The pool existed to parallelise per-viewer FOW work that no longer runs on the client, and its shared-memory synchronisation would have required a per-thread stack contract for multiple module instances in one address space — a corruption hazard that no parity test could detect, since the corpus is single-threaded. The Render Worker also never blocks on geometry: it reads the newest completed result from a sequence-numbered double buffer, because a blocking wait stalls the frame loop while the DOM stays smooth, producing the same misattributed symptom this topology exists to prevent for networking.
  2. OPFS Asset Streaming & Graceful Degradation: 8km x 8km maps exceed standard browser GPU memory (1GB–2GB) and renderer-process memory limits. The frontend implements Frustum Culling & OPFS-based Spatial Streaming. The streaming worker dynamically reads and decrypts KTX2 textures of visible grids directly from the local OPFS drive.
    • A per-profile resident VRAM budget, in the same normative form §5.3 uses for visibility masks. OPFS streaming addresses CPU-side residency and does nothing for VRAM, so the GPU-side ceiling needs its own explicit byte budget per profile, enforced by dropping LOD rather than by failing an allocation. Without one, “we stream from disk” is quietly credited with solving a problem it never touched, and the symptom is intermittent device loss on large maps (Guardrail 1) — among the hardest failures to attribute, because it presents as a driver problem.
    • Durability is requested, not policed (ADR-118). The scavenger below governs what we delete; it says nothing about what the browser deletes. OPFS is evictable storage, and under storage pressure a browser reclaims it without asking — so the client calls navigator.storage.persist() to request an exemption and pre-flights StorageManager.estimate() before a large campaign load, surfacing a real “this campaign needs ~N GB and you have ~M” figure rather than discovering the shortfall mid-session. A persistence grant is a request the browser may refuse, which is why this is stated as a request: it reduces involuntary eviction and cannot prevent it, and the recovery path is an ordinary re-download.
      • Two defences considered and rejected, recorded because both are intuitive. A UI warning telling players not to clear browser data is refused: ADR-054 records that clearing site data is the only escape from a stale-Service-Worker lockout, so discouraging it makes that lockout stickier — the platform would be protecting its cache at the cost of the user’s only recovery. Throttling repeat downloads is refused on three independent grounds: §10.2 puts asset egress at $0, so it defends a cost line that does not exist; a GM prepping across three devices is indistinguishable from the abuse it targets, which is the compromise-in-a-constant trap ADR-087/105 already ruled on; and per-account per-bundle download counts are new retained behavioural data owing an ADR-079 rights-holder answer and an ADR-078 erasure path. Any future control here needs a measured quantity first — the plausible cost is R2 Class B operations and origin CPU, and neither is registered.
    • LRU Disk Scavenger: To prevent OPFS from exhausting physical storage, an LRU cache manager purges campaign assets unaccessed for over 30 days. It runs at startup, on a periodic in-session timer, and before any reservation that would approach the quota — explicitly not only on tab closure, because pagehide/beforeunload grant no time for a multi-gigabyte purge and never fire at all on a force-quit, browser crash, or OS kill. A design that only cleans up on graceful exit accumulates until it fails.
    • Quota Exhaustion Is a First-Class State: before streaming, the worker checks navigator.storage.estimate() and reserves space; if the reservation cannot be met after a purge pass, the session degrades to the Aggressive LOD tier (Guardrail 7’s OPFS row) with a user-visible notice. A mid-stream QuotaExceededError must never surface as a partially written chunk — which renders as black textures or kills the streaming worker while the UI stays responsive, one of the hardest symptoms to attribute correctly.
    • Graceful Degradation & Aggressive LOD Throttling: If OPFS is blocked (e.g., in Incognito mode or on low-storage devices), the system triggers a graceful degradation, falling back to legacy JS memory loading. To prevent V8 memory crashes, this fallback mode activates Aggressive LOD Throttling, strictly rejecting 4K textures and high-poly models, requesting only the lowest-resolution assets from the server to guarantee stability on constrained devices.
  3. LiveKit SFU Infrastructure (Ephemeral Data & Audio): While authoritative game state flows through the .NET backend via SignalR, non-authoritative high-frequency data (e.g., 3D mouse cursors, ruler measurements, map pings at 60Hz) bypasses the Orleans server entirely. Utilizing a dedicated LiveKit SFU (Selective Forwarding Unit), this data is routed via WebRTC DataChannels. Unlike a P2P Mesh, the SFU ensures $O(N)$ scaling—clients upload exactly 1 stream and download N streams, drastically reducing mobile device CPU and bandwidth load while completely shielding the .NET backend from garbage collection spikes.
    • The Golden Rule of State Sync: “If it is an in-progress action (Process) and its payload contains no value derived from privileged geometry, route it through WebRTC. If it is a finalized action (Result), route it through SignalR.” The original rule was a latency heuristic and said nothing about confidentiality — and defining a channel by its transport rather than its content is what let a disclosure into it (see the content rule below).
    • Ephemeral Payload Content Rule (ADR-038). A payload on this channel may contain only (a) values derived from the sender’s own input — cursor coordinates, raw ruler endpoints, pen stroke vertices — and (b) transforms of entities the sender currently holds a lease for, sent only to identities already permitted to observe that entity. It may not contain any value computed from the sender’s occluder or visibility set. Payload types are a positive list with a schema each, enforced at the SDK/serialisation boundary; adding one is a reviewed act.
      • Why identity filtering cannot cover this. ADR-014’s allowlist controls who receives, not what the payload implies. A Keeper’s client legitimately holds the full occluder set, so a vision cone it computes locally is shaped by secret doors and hidden creatures — and players are entitled to see the Keeper’s cursor and placement previews. The leak is not an entity; it is a shape, and no per-recipient filter can redact the geometry a silhouette implies. Players read the notch in the cone and infer the secret door’s position and angle, from a completely unmodified client, as default behaviour.
      • Sender-side enforcement is legitimate here, and this is not a reversal of ADR-034. There, the client was the adversary and client-side culling was one patched line from being disabled. Here the sender owns the secret: a modified Keeper client leaking the Keeper’s own secrets to the Keeper’s own players harms only that table. What had to change is that the unmodified client was leaking, which is not a coherent position to hold.
    • Server-Issued Outbound Allowlist & Targeted WebRTC Routing (ADR-014): The server does not ship a graph of “who can see whom”. Each client receives only its own minimized outbound allowlist — the set of participant identities it is currently permitted to transmit to — and nothing about third-party relationships. Undisclosed entities (hidden NPCs, unrevealed GM tokens) never appear in any client’s allowlist input, so the allowlist cannot be mined to infer their existence or bearing. The allowlist is recomputed every tick (20Hz), not only on token drop, so a token moving behind cover during a drag stops being transmitted within one tick instead of remaining trackable for its whole duration.
      • That guarantee needs an input the server did not previously have (ADR-050 rule 7). Recomputation frequency was never the problem: §7.1 routes in-progress positions entirely around the backend, so the server knew only the pre-lease anchor and would have been filtering against a position the entity left seconds earlier. A Keeper dragging a hidden creature from a revealed area into an unrevealed one would have streamed its every coordinate — including its final hiding place — to every player for the full drag, while this document claimed the opposite. Lease holders therefore publish an advisory position to the server at tick rate, coalesced into one message per room per tick, used for AOI and allowlist recomputation only: never for events, persistence, trigger evaluation, or LOS authority. It is a deliberate, stated exception to “zero requests hit the backend” for this path, and it is the price of the sentence above being true.
    • Client Routing Is Not a Security Boundary: destinationIdentities filtering is a bandwidth and latency optimization performed by the sender, and is explicitly not load-bearing for confidentiality. Any datum whose disclosure must be prevented is withheld by the server at the AOI / Visibility Channel stage (§8.2) and is never present on a peer that could leak it. A compromised sender can therefore only over-share its own state, which every receiver discards under the ownership-lease receiver rule (§5.1.1).
    • Adaptive Ephemeral Rates & Bandwidth Budget: 60Hz is a ceiling, not a constant. Publish rate scales down with participant count (60Hz ≤ 6 peers, 20Hz ≤ 16, 10Hz beyond) because SFU fan-out is O(N) per sender and O(N²) per room in aggregate: 50 participants at 60Hz is ~150KB/s inbound per client and multiple GB of relayed egress over a four-hour session. ADR-014’s “zero bandwidth overhead” is true only of our own .NET tier — SFU relay is metered by the provider and is an explicit line item in §10.2/§10.3, not a free channel.
    • 3D Dice: Result-First via Per-Die Trajectory Index (ADR-041, superseding ADR-028): the authoritative result comes from the server’s commit–reveal CSPRNG (§4.1), so free local physics that settles on a different face is not an option — it forces either a visible post-settle flip or a chat log that contradicts the dice. That diagnosis was right; “issue a physics seed authored to terminate on the authoritative face” was not implementable. It requires inverting seed → outcome: a 1d100 has 100 outcomes and can be tabulated, but a 10d6 damage pool has ~60 million ordered outcomes, rejection sampling would average tens of millions of simulations, and §5.2 allocates no compute to dice physics anywhere.
      • The mechanism instead: an offline, CI-verified trajectory table keyed (dieType, faceValue) with K authored variants, plus a tray layout table of non-overlapping landing slots sized by die count. The server picks a trajectory and a slot per die; the wire carries {dieType, face, trajectoryIdx, slotIdx} per die plus a flavour seed for camera and lighting. Every client plays back the same deterministic trajectory, so all viewers see identical motion and no result is ever re-decided by the animation. CI asserts each trajectory terminates on its declared face, stays inside the tray, and does not intersect other slots’ trajectories.
      • Two honest consequences. Dice motion is authored, not emergent — variety comes from K variants × slot permutations, and a new die type or tray requires a pipeline run. And dice cannot interact physically with the map or with tokens: they roll in a dedicated tray volume, not across the battlefield. The alternative (guided canned animations without collision resolution) would have made ten dice interpenetrate, trading ADR-028’s visible face-flip for visible clipping.
      • Simultaneously animated dice are capped, with the remainder shown as a grouped result on a shorter animation — the same concurrency-cap pattern as §9.4’s hardware decode sessions.
    • Dynamic FOW & Lighting Previews — local only, or server-issued (ADR-038): the Keeper’s own vision-cone and lighting previews render locally and are not shared over WebRTC, because their shape is computed from geometry the Keeper alone holds. Where a player legitimately needs to see a teammate’s vision cone, the server issues it, filtered through the recipient’s own visibility — it is server-computed anyway (ADR-034) and a cone is a handful of numbers, so the bandwidth this document previously avoided by shipping it peer-to-peer is negligible. The cost is real but small: preview latency rises from peer-to-peer to tick cadence plus RTT.
    • Collaborative Whiteboarding: High-frequency pen strokes are streamed via SFU. The final vector shape is sent to the backend as a single Command upon mouse release. Ruler measurements carry raw endpoints only — any snapping to geometry is applied locally by each receiver against its own geometry, so two players may see slightly different snapped results, which is correct: each sees what it is entitled to see.
    • Spatial Audio Telemetry: Clients broadcast their avatar’s 3D coordinates via DataChannels directly to other peers, allowing local Web Audio API nodes to compute attenuation and 3D panning with zero server latency.
  4. Deterministic Input & Client-Side Prediction: To eliminate the 50–100ms input lag inherent in a “Server First” architecture, the frontend features a Deterministic Input Interceptor. When dragging a token, the client acquires an Ephemeral Ownership Lease (§5.1.1) and predicts using the same Descent.Geometry revision the server runs (§5.3).
    • The guarantee, scoped (ADR-034, ADR-050). For the acting client’s own entity over geometry it has been disclosed, prediction and server validation agree, and the hand-back is gapless with zero visual displacement — not because a time bound is met, but because the peer’s final value and the server’s authoritative value are the same value, and the transition cross-fades over one interpolation window. Rubber-banding is reserved for genuine rejection: an illegal move, a lost or expired lease, or a world mutated by another player mid-drag.
    • What the earlier claim over-promised. “Predicts bit-exactly and produces no correction at all” was stated unconditionally, but bit-exactness holds only over identical inputs, and §9.2 deliberately withholds undisclosed occluders from the client. Any prediction whose result depends on hidden geometry — every visibility query, and pathing that could route through an unrevealed door — therefore diverges by design, and the divergence is observable. Visibility is consequently presented, never predicted, and prediction/authority corrections are applied on a fixed cadence with uniform treatment so their timing carries no information.
    • Authoritative multi-step paths are issued by the server (§5.4.3) and animated by the client; the client never derives a competing path for authoritative movement. This also gives the server the per-cell path it needs to evaluate traps and other triggers along a drag (§5.4.4), which drop-point-only evaluation would miss entirely.
  5. Hybrid UI Rendering: Dynamic Atlases & Thin Instances: To prevent the “500 Goblins” problem (catastrophic DOM Layout Thrashing when panning across hundreds of HP bars and names), the engine strictly enforces a Hybrid Rendering Boundary.
    • World-Space UI (WebGPU): HP bars, names, and status icons are baked into a Dynamic Canvas2D Texture Atlas. WebGPU utilizes Thin Instances (Hardware Instancing) to stamp this texture over 10,000 tokens in a single Draw Call with zero DOM overhead.
    • Hardware Fallback Strategy (Graceful Degradation):
      • Profile A (High-End): WebGPU Compute + 4K Dynamic Atlases.
      • Profile B (Mid-Range): WebGL 2.0 ANGLE_instanced_arrays + 1024x1024 Atlases.
      • Profile C (Potato/Mobile): Aggressive Distance LOD (Frustum Culling hides UI when zoomed out), capped to a maximum of 50 visible elements rendered via standard HTML DOM — which also means Profile C renders CJK labels natively and correctly (§9.1.1).
  6. PWA Service Worker & Resource Boundary: Complementing OPFS, the frontend deploys a strict responsibility boundary. Massive binary 3D assets are governed entirely by OPFS, while the Service Worker CacheStorage is dedicated solely to caching lightweight code and UI resources (HTML, JS, CSS, JSON Schema). Volatile UGC schemas rely on Network-First with ETag validation.
    • The Client Update Contract (ADR-054). A blanket Cache-First policy for code, combined with §8.1’s handshake refusal on an unsupported protocolVersion, is a lockout: the Service Worker re-serves the same stale bundle on every reload, so “please upgrade” cannot be obeyed, and the only escape — clearing site data — destroys OPFS assets and any unmerged Yjs edits, which is precisely the data loss ADR-026 exists to prevent. With four protocol surfaces added in §5.1.1 alone, version changes are certain rather than hypothetical, so:
      1. Code assets are keyed by build id, and old build caches are purged on activate (no unbounded growth).
      2. The app shell — HTML plus the SW registration — is never Cache-First; it is Network-First or short-TTL stale-while-revalidate. Cache-First is for content addressed by build id, which the shell is not.
      3. A cacheable, unauthenticated endpoint publishes the minimum supported build and protocolVersion, checked before connecting, so in the normal case the update happens and the player never sees an error.
      4. A refusal that still occurs triggers a bounded forced updateskipWaiting, claim, reload — which first flushes the offline Intent queue and unmerged Yjs updates, with a notice and a grace period. Reloading before flushing turns the fix into the original data loss by another route.
        • Flushing is sending, not acceptance, and the two halves behave differently. Yjs is a CRDT and branch-agnostic (ADR-020, ADR-042), so its merge always succeeds. The Intent queue does not: Guardrail 4’s Timeline Rule rejects the whole batch if the room’s timeline advanced past — or forked away from — the baseline those Intents assumed, and a GM scrolling the node tree is an ordinary occurrence, not an exotic one. The flush result is therefore resolved before the update begins: on a batch rejection the player first sees the “these actions could not be applied” review Guardrail 4 already specifies, and the reload proceeds only after they acknowledge it. Writing both halves into a single clause is what hid this — an update that has already started has nowhere left to show an explanation.
      5. If the client is already current and still refused, show a terminal explanation, not a spinner (Guardrail 6).
      6. A forced reload mid-session is disruptive even when handled well, so breaking protocol changes are batched and released away from peak hours — a release-checklist item, not a hope.
  7. Multi-Window State Synchronization: To support GM multi-monitor setups natively, pop-out UI panels (e.g., combat trackers) function as Thin Clients. Instead of opening redundant SignalR connections, the Master Window syncs state locally to Slave Windows with 0 extra backend load. The transport is split by traffic class, because one channel cannot carry both:
    • BroadcastChannel for low-frequency control messages (panel open/close, selection, JSON AST descriptors, discrete state changes). BroadcastChannel cannot transfer a SharedArrayBuffer, so it is never used for shared state.
    • SharedWorker (or a MessageChannel handshake) for anything continuous. Pop-outs that track live values attach to the shared worker and receive the same published triple-buffer discipline as the main window. Pushing 60Hz JSON between windows over BroadcastChannel — the fallback a DataCloneError would otherwise force — recreates precisely the per-frame serialization cost §9.1 exists to eliminate, and leaves the second monitor visibly lagging the first.
    • Pop-outs are declared low-frequency by contract: panels that require frame-accurate tracking of 3D positions are not eligible for pop-out and remain docked in the main window.
  8. Transport Decision: SignalR/WebSockets Is the Baseline; WebTransport Is a Gated Evaluation (ADR-029): The supported production transport is SignalR over WebSockets with MessagePack/FlatBuffers framing (§8.1). WebTransport is explicitly not mandated, for three reasons that must be settled before it could be: (1) SignalR has no WebTransport transport (WebSockets, SSE, and Long Polling only), so adopting it means giving up hubs, groups, and the backplane and re-implementing them; (2) it requires end-to-end HTTP/3 with Extended CONNECT through the Cloudflare edge and the ACA ingress, which is unverified on this path and must be proven with a spike before any commitment; (3) its datagram channel would duplicate the role WebRTC DataChannels already own for ephemeral data (§9.6.3), reintroducing a second unordered path for the same payloads and the arbitration ambiguity §5.1.1 exists to remove. TCP Head-of-Line blocking is also materially mitigated already by the fixed 20Hz tick plus the adaptive interpolation buffer (§8.3). Any migration is therefore scoped as a Phase 5 evaluation with a measured latency benefit, not a standing requirement.

9.7 Profile C Interaction Design: the Small-Screen Client (ADR-066)

Everything this document has said about Profile C is about cost — asset tier, draw calls, VRAM, the baked 2D bake requirement (ADR-005). None of it says how a person plays. That omission has been hiding a decision rather than deferring one: §2.2 labels Profile C “Tablets / Legacy Devices”, while the roadmap, the FinOps model and the Guardrail 7 matrix have quietly assumed a phone is covered. A 6-inch phone and a 10-inch tablet are not one client with different CSS, and the difference is not screen area — it is that one of them can display a tactical map and a character sheet at the same time and the other cannot. Every design below follows from that single fact.

9.7.1 Profile C Splits by Viewport Role, Not by Device Name

  • C-Tactical — landscape, ≥ 8-inch effective viewport (tablets, small laptops in the legacy asset tier). Map and a docked sheet panel coexist. All player actions are available.
  • C-Companion — phones and any viewport too small to hold both surfaces. Sheet-first. The map is present, but demoted from a workspace to a locator: it answers “where is everyone and what can I reach”, not “let me compose a tactical scene”.

The alternative a future reader will re-propose — one responsive layout that scales continuously down to 375px — is rejected explicitly, and the reason is not aesthetic. A continuous layout forces every UI surface to be simultaneously valid at every width, which in practice means each one is designed for the largest width and then hidden below a breakpoint. What gets hidden is decided per component by whoever wrote it, so the composite result at 375px is an arbitrary subset of the game that nobody designed and nobody tests. Declaring two named roles means each has an owner, a layout, and a Guardrail 7 column.

Unsupported on C-Companion, declared rather than degraded: the Keeper/GM role, map authoring and Kitbashing (§6.3), the Rete.js node editor and Monaco macro authoring (§6.4), and whiteboard authoring (§9.3 — reading and following along is supported). This follows §2.2’s existing rule that an unverified feature is disabled rather than silently degraded. A GM who opens the app on a phone is told the session can be joined but not run, before they are relying on it at a table.

9.7.2 The Input Model: a Locator Is Not a Camera

  • The camera is constrained, and rotation is removed on C-Companion. Pan and pinch-zoom with snapped zoom steps; no free rotation. This is a play-experience decision before it is a performance one: table talk is spoken in a shared frame of reference (“he’s coming around your north side”), and a player whose map is rotated 40° relative to everyone else’s silently loses that channel while believing they are looking at the same thing. C-Tactical may offer 90° snap rotation, which preserves a shared vocabulary; free rotation is not offered on either.
  • The finger occludes the target, and this is a hard constraint, not a polish item. A 44pt touch target over a token drawn at 18px means the token is under the thumb for the entire drag — the player cannot see the thing they are moving, which is most of what a VTT is for. Dragging therefore lifts an offset proxy: a ghost of the token rendered above the contact point with a crosshair marking the true position, and the legal destination set highlighted underneath. This is why the locator framing matters: highlighting legal destinations is only affordable because Profile C’s world is coarse.
  • Four gestures, and no double-tap. Tap selects, long-press opens the radial context menu, single-finger drag on a selected owned token is a move intent, two fingers pan and zoom. Double-tap is rejected outright: it collides with the platform zoom gesture, and supporting it obliges every single tap to wait out the double-tap window before it resolves. Paying a fixed input latency penalty on every interaction, in a client whose entire netcode chapter exists to remove tens of milliseconds, would be a self-inflicted regression larger than the ones §8.3 is tuned against.
  • No hover means no hover-only affordance. The desktop UI is hover-rich — range previews, tooltips, threat highlights. Each of those needs a declared touch equivalent (selection-triggered, long-press, or persistent), and “it appears on hover” is not a specification for a touch client. This is recorded as a Guardrail 7 row rather than left to per-component discovery.

9.7.3 Where the Small Screen Meets the Lease Protocol

This is the part of mobile that is architecture rather than layout, and the current design has a hole in it.

  • A lease cannot be acquired at drag-start without a stall. §5.1.1 and ADR-050 require an ephemeral ownership lease before an entity’s movement may be predicted. On a mobile network a round trip is routinely 80–200ms and worse across an LTE/5G handoff, so requesting the lease when the drag begins would freeze the token under the player’s finger for the first fifth of a second of every move. The sequence is therefore: request speculatively on touch-down; render the drag as a local planning overlay until the grant arrives; switch to predicted movement on grant. If no grant has arrived by lift, the move is submitted as an ordinary server-driven request and the token shows the pending affordance rather than snapping — reusing ADR-050’s staleAnchor presentation instead of inventing a second vocabulary for the same state.
  • A backgrounded phone must release its leases, and must stop counting as room activity (ADR-067). Mobile operating systems freeze timers, stop requestAnimationFrame, and will close a socket held by a background tab; the player who answers a phone call mid-combat is not a rare case, it is every session. Two independent failures follow from ignoring it, and neither is a mobile bug — both are server-side consequences:
    1. A lease held by a frozen client suppresses that entity’s transform from every snapshot until expiry (§5.1.1). ADR-051 already rejected long-lived offline leases for exactly this reason, but it reasoned about offline, and a backgrounded phone is not offline — its socket may well still be open. The entity freezes at its staleAnchor for the whole room while the owning player is looking at their lock screen. visibilitychangehidden therefore releases all held leases immediately, and does not wait for expiry.
    2. A backgrounded client must not be counted as room-activation liveness (ADR-049). Campaign-branch autosuspend is gated on room activity; six phones left backgrounded overnight in a joined room would hold a branch — and its cost — awake indefinitely while nobody is playing. Liveness requires a foregrounded client, and this is the second time in this document that an activity signal has had to be narrowed from “a connection exists” to “someone is actually there”.
    • Resume is a resync, not a reconnect. Returning to the foreground uses Guardrail 1’s path: request a per-viewer Full Snapshot (§8.2) before resuming delta application. Backgrounding is never treated as data loss, and the offline Intent queue (Guardrail 4) is not flushed on resume without the same premise check any other reconnect performs.

9.7.4 The Character Sheet Problem, and Why It Is an SDK Problem (ADR-068)

A Call of Cthulhu 7E sheet is a dense two-page form. Reflowing it into a 375px column produces a correct, complete, unusable document — every field present, nothing findable, and a die roll four scrolls from the value it depends on.

  • The sheet is decomposed into task-shaped surfaces, not scaled. Roll, Combat, Inventory, Story. The surface presented by default is derived from the room’s current phase — combat turn active opens Combat — because the alternative is a player navigating a menu during their own turn while five people wait.
  • The platform cannot fix this per cartridge, which is the architectural point. Sheets are authored as JSON AST UI descriptors (§6.4, §9.5 Guardrail 5, §12), so a hand-tuned small-screen layout is not something the platform can write on a community cartridge’s behalf — and a cartridge that renders unusably on phones is indistinguishable, from the platform’s side, from one that renders fine. Therefore: UI descriptors declare semantic role and priority per field group, and the renderer performs profile-specific layout from those annotations. Registration-time validation (§12) rejects a descriptor that has no viable C-Companion rendering, in the same way and for the same reason that a bundle without a 2D bake is refused Profile C availability (ADR-005). Both are the same rule: a profile is only real if the content pipeline is obliged to feed it.
  • The sheet occludes the map, so the authoritative channel must not be occluded with it. On C-Companion the sheet is a full-height layer over the map, with a persistent situation strip: turn order, own HP and conditions, and an unread-events indicator. The strip is DOM, and it is fed by the Network Worker’s DOM-facing channel (ADR-053), never by the Render Worker. The reason is exactly Guardrail 1’s: a phone whose renderer is throttled or whose canvas is not visible publishes nothing, and a strip sourced from it would sit there showing an HP total from four minutes ago while looking perfectly live. This is ADR-053’s ownership rule applied to the case that makes it unavoidable rather than merely correct.

9.7.5 Mobile Capabilities the Guardrail 7 Matrix Did Not Have Rows For

Three conditions are specific to this client class and are added to the matrix rather than handled ad hoc, since a condition handled per-component is a condition handled inconsistently:

  • Viewport class and touch-only input — the capability that selects C-Tactical or C-Companion and disables every hover-only affordance.
  • Application backgrounded / OS throttling — the resume-by-resync path, lease release, and liveness exclusion of §9.7.3.
  • Sustained thermal budget — a phone sustaining 3D throttles within minutes, so Profile C declares a sustained frame budget rather than a peak one, with an automatic and visible reduction step (frame-rate cap, animation suspension, effect class downgrade under ADR-061). A budget met only for the first three minutes of a four-hour session is not a budget, and a silent reduction is indistinguishable to the player from the app getting worse for no reason.

10. Cloud, Server Deployment & Infrastructure Architecture Specification

Descent VTT adopts a “Cost-Optimized FinOps + Low-Latency Hybrid Network” infrastructure design philosophy. “Hybrid” here names the network — managed cloud plus edge — and not the hosting model, which is a distinction worth stating because it did not previously need making: the system scales up into a cloud fleet serving a global player base, and does not scale down out of the platform’s control.

Deployment Profiles (and what each one honestly gives up): two profiles are defined and no third is supported. Both are operated by the platform.

ProfileTopologySubstitutions & Accepted Limits
Managed SaaS (§10.1)Multi-replica ACA silos + Neon + Garnet + LiveKit Cloud + always-on WorkerFull feature set. Carries the always-on cost floor documented in §10.2.
DR Tier (ADR-030)Google Cloud Run, single instance, reduced capacityExplicitly not a hot mirror. Distinct ClusterId, no backplane, capped concurrent rooms, cross-silo features disabled.

The Self-Hosted Single Container profile was removed by ADR-108 (Architecture_Decision_Rulings_R16.md). It was previously described here as “a genuine first-class target rather than a marketing line”, and the obligation that flowed from that — every component the SaaS profile places in a managed service must have a documented in-process or user-supplied substitute — is withdrawn with it.

What that removal is actually worth is the caveats, not the container. A deployment the platform does not control cannot carry a platform guarantee, so each guarantee written against that profile acquired an exemption: media persistence (ADR-081) was scoped away from it, content protection (§6.1) was unavailable in it because key custody could not be assured off-platform, and ADR-083 declined a hardcoded region partly because that operator’s geography was unknowable. An invariant with an exemption is not a smaller invariant — it is a weaker claim a reader must remember to qualify. Those exemptions are deleted rather than reworded, because there is no longer a second deployment for them to exclude.

The DR Tier is now the only reduced-capacity topology, and that is a stated cost rather than a tidy outcome: it alone now exercises the no-backplane path, and it is the profile that runs only during an incident.

10.1 Production SaaS Infrastructure

To leverage the elasticity of serverless compute without the overhead of heavy Kubernetes (K8s) and JVM infrastructures (notably Elasticsearch), the official managed environment entirely utilizes a Serverless Container + Zero-Egress Storage + Edge Acceleration combination based on standard .NET 10 CoreCLR (JIT).

Pragmatic Architecture Decision: JIT over Native AOT The architecture explicitly chooses the standard .NET 10 JIT compiler over Native AOT. While AOT offers sub-10ms cold starts, JIT enables dynamic macro execution via Descent.Sandbox (Jint) to establish absolute backend authority over player scripts, alongside enabling rich reflection-based libraries. The team accepts a minor ~1-3s cold start penalty when waking up an idle server instance from zero in exchange for ultimate developer velocity and higher peak throughput during long-running 4-hour game sessions via Tiered Compilation. Note: Despite running on JIT, the architecture heavily favors AOT-optimized libraries (e.g., Dapper.AOT, System.Text.Json Source Generators) in performance hot-paths. These libraries generate code at compile-time, completely bypassing runtime reflection overhead and maximizing throughput even within a JIT environment. Correction on cold-start framing: the JIT-vs-AOT choice is defensible on its own terms (interpreter hosting, reflection-based mapping, higher steady-state throughput across a 4-hour session), but it is not the dominant term in wake latency, and Native AOT would not deliver “sub-10ms cold starts” for a full ASP.NET + Orleans + Marten host. Wake time is dominated by scheduling, image pull, database resume, and cluster join — see the measured chain in §9.5 Guardrail 6.

┌──────────────────────────────────────────────┐
│ Global Edge Network (Cloudflare) │
│ DNS · Load Balancer · WAF · CDN │
└────────────────────┬─────────────────────────┘
┌─────────────────────────┴─────────────────────────┐
▼ ▼
┌──────────────────────────────────────┐ ┌──────────────────────────────────────┐
│ Object Storage & Edge Assets │ │ Global Anycast Routing │
│ Cloudflare R2 (Zero Egress) │ │ HTTPS / WSS · Sticky Ingress (opt.) │
│ - 3D models (.glTF / .glb) │ │ (ADR-032: no correctness depends) │
│ - KTX2 / Basis Universal textures │ └──────────────────┬───────────────────┘
│ - Baked 2D tiles (ADR-005) │ │
│ - Verified cold backups (ADR-036) │ ▼
└──────────────────────────────────────┘ ┌──────────────────────────────────────┐
│ API Gateway & Real-Time Compute │
┌──────────────────────────────────────┐ │ Azure Container Apps [PRIMARY] │
│ LiveKit SFU (Ephemeral + Media) │◀──────▶│ .NET 10 CoreCLR (JIT) │
│ - WebRTC DataChannels / MediaStream │ │ - Orleans silos · RoomGrain = T0 │
│ - Voice · video · cursors · pings │ │ - 20Hz tick outside mailbox (033) │
│ - Metered relay (see §10.3) │ │ - HTTP-driven Scale-to-Zero │
└──────────────────────────────────────┘ └───┬──────────────┬───────────────┬───┘
│ │ │
┌──────────────────────────────────┘ │ └───────────┐
▼ ▼ ▼
┌──────────────────────────────────┐ ┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ Cache & Low-Freq Backplane │ │ Database & Event Persistence │ │ Always-On Worker (ADR-043) │
│ Microsoft Garnet + FusionCache │ │ PostgreSQL 18 on Neon │ │ Descent.Vtt.Worker │
│ minReplicas: 1 → always-on │ │ - Marten event store (partit.) │ │ - Marten async daemon │
│ - NOT load-bearing (ADR-032) │ │ - Orleans membership (no idle) │ │ - Yjs flatten · archival │
│ - Byte-identical T1 copy (058) │ │ - PostGIS · pgvector · pg_trgm │ │ - per-shard leases, min 2 (043) │
└──────────────────────────────────┘ └──────────────────────────────────┘ └──────────────────────────────────┘

Two omissions from this topology are deliberate and load-bearing. (1) Asset Baking Jobs (§6.2) are a separate event-driven ACA Jobs pool fed by Azure Service Bus with KEDA queue-depth scaling — they are not drawn inside the real-time compute box because co-locating them is the “Asset Baking Poison” this architecture exists to avoid. (2) The DR tier (ADR-030) is not drawn as a peer of the primary compute box, because it is not one: Cloud Run cannot form a multi-silo Orleans cluster and cannot reach the VNet-internal cache, so it is a reduced-capacity continuity tier, not a mirror.

1. Compute & API Gateway (Scale-to-Zero)

  • Primary (Preferred): Azure Container Apps (ACA). Chosen as the primary compute layer to align perfectly with the .NET 10 backend ecosystem. ACA supports native HTTP-driven scale-to-zero, meaning 0 billing costs when no users are playing.
  • Fallback (Secondary): Google Cloud Run. Serves as the disaster recovery fallback, offering identical scale-to-zero capabilities.
  • (Note on AWS ECS Fargate): Avoided for standard HTTP scale-to-zero as Fargate lacks native scale-to-zero capabilities for HTTP traffic without complex external scaling triggers, making ACA or Cloud Run vastly superior for VTT FinOps.
  • Elasticity Policy (what scale-to-zero looks like against a real weekly cycle):
    • During off-peak cold windows (overnight and weekday daytime in the primary market’s timezone), the request tier scales to zero instances and per-request compute billing goes to zero. The always-on floor in §10.2 remains — the two statements are not in conflict, and both must be quoted together.
    • At weekend-evening session peaks, the load balancer scales out on incoming SignalR/HTTP concurrency, tracking the actual number of live rooms rather than a provisioned guess. Note the interaction with §4.4 placement: scale-out adds empty silos, so the weighted placement director — not activation count — decides where heavy rooms land.
    • Long sessions hold a WebSocket open for hours, so a populated room keeps its replica warm by definition. Scale-to-zero is a property of the idle system, never of an occupied one.
  • Isolated Background Worker Profile: To prevent the “Asset Baking Poison,” heavy 3D geometry processing (KTX2 compression, NavMesh generation) is offloaded to a completely independent pool of Azure Container Apps background jobs, ensuring they never block real-time RoomGrain connections.

2. Authoritative Database & Search (PostgreSQL 18)

  • Single Store Architecture: Consolidates Event Sourcing (Marten), Document Storage (JSONB), and Spatial Queries (PostGIS) into a single PostgreSQL 18 instance.
    • Acts as the Orleans ADO.NET Clustering Provider, offering a strongly consistent Silo membership table to strictly prevent split-brain during scale-to-zero serverless churn. (This is the branch that must not autosuspend — see §10.1.1.)
    • Column Strategy: stable business and index-critical fields live in relational columns; heterogeneous, ruleset-defined shapes (investigator sheets, dynamic inventories, mental-disorder state) live in JSONB trees. The split is deliberate: relational columns get real constraints and cheap indexes, while JSONB absorbs the schema variance that cartridges introduce (§7.1 Attribute Schema Registry) without a migration per ruleset.
    • Flattened Projections via Dapper.AOT + Npgsql: read paths execute hand-written, flattened SQL projections rather than routing complex queries through a full ORM. This rejects EF Core’s change-tracker and expression-translation overhead on the hot path — a deliberate trade of some developer convenience for predictable query plans and compile-time-generated materialization (see the AOT-library note in the JIT decision above).
  • pg_trgm Full-Text Search: Replaces heavy JVM-based Elasticsearch. PostgreSQL’s native GIN indices with pg_trgm deliver single-digit-millisecond warm-cache searches over rule compendiums and spell lists without a second search tier to operate — the correct trade for this workload. Latency is stated per cache state rather than as one number: a cold first query after a Neon compute resume pays the resume plus uncached GIN page fetches from the storage layer (hundreds of milliseconds, and seconds if the compute was suspended), so search SLOs are published as cold/warm pairs and the UI reflects the first-query cost. Index residency for the GIN and pgvector HNSW indices is sized from the actual corpus and is materially more than 50MB; the earlier figure was not derived from a measured corpus and is withdrawn.
    • “Without a second search tier to operate” is NARROWED by ADR-168 and is no longer true of the platform as a whole. (Amended 2026-08-12. The clause is corrected here rather than deleted, because a reader arriving at this bullet from the FinOps argument needs to know which way the change went.) §7.2’s retrieval corpus — the licensed and campaign-derived chunks an AI answer cites — now lives in an out-of-process search engine with a local CPU embedder, and that engine is a second tier with its own memory, its own store and its own operational surface. What is unchanged is this bullet’s subject: pg_trgm over GIN keeps the structured lookups it already serves — rule compendiums, spell lists, the flattened Yjs search text of §9.3 — and ADR-168 adds no requirement to move them. The two are not competing for one workload; they answer different questions, and the FinOps claim above should be read as scoped to the queries it names rather than to search in general. ADR-168 records the exit cost of the new tier rather than implying it has none.

3. SignalR Backplane & Cache (Microsoft Garnet)

  • Microsoft Garnet Integration: The architecture completely replaces Redis with Microsoft Garnet (a next-generation cache from Microsoft Research).
  • .NET Native Synergy: Written entirely in C#, Garnet eliminates GC pauses, offers superior multi-thread scaling over single-threaded Redis, and integrates flawlessly into the .NET 10 ecosystem.
  • Zero Code Modification (RESP Compatibility): Garnet is 100% RESP (Redis Serialization Protocol) compatible. The ASP.NET Core StackExchange.Redis client (used for the SignalR backplane and FusionCache) requires zero code modifications—only a connection string update—to benefit from Garnet’s extreme performance.
  • Cache Payload Serialization: all C#→cache payloads (including MemoryPack room snapshots) use MemoryPack — zero-allocation, reflection-free, and Native-AOT-compatible, so the serializer never becomes the reason a hot path allocates.

4. WebRTC Real-time Communications & SFU

  • LiveKit SFU Edge Network: To completely resolve the O(N^2) bandwidth explosion issues inherent in traditional P2P Mesh architectures when handling high-frequency 3D coordinates and multiple audio/video streams, the architecture exclusively utilizes a LiveKit SFU (Selective Forwarding Unit).
  • Built-in NAT Traversal: LiveKit includes built-in TURN relay capabilities, seamlessly resolving connection issues for players behind strict NATs or corporate firewalls, eliminating the need for standalone Coturn servers.
  • State & Media Offloading: High-frequency voice, video, 3D spatial audio (Web Audio API PannerNode), and ephemeral states (cursor/ruler) are exclusively handled by LiveKit’s WebRTC DataChannel/MediaStream, completely offloading these tasks from the central server API. The offload is of the transport, not of the disclosure decision — the panner’s input is bounded by ADR-167 clause 3, and audibility for a concealed source remains server-computed under ADR-091.

5. Storage Carrier & Zero-Egress Tax

  • Cloudflare R2 Object Storage: Stores 3D models (.glTF/.glb), KTX2/Basis Universal texture atlases, and map packs. Cloudflare R2 incurs zero cross-network download egress fees, completely eliminating the expensive bandwidth tax associated with AWS S3.

6. Cold Path & Background Workers (Client-Side Offloading)

  • Client-Side Compute (Prediction, Not Authority): The client runs the same Descent.Geometry crate as the server (§5.3) to predict LOS/FOW and paths. This removes input latency and lets the server ship compact authoritative results instead of verbose intermediate state — but it does not relieve the server of the computation. Visibility is a security property and is always computed server-side (§5.4, ADR-017). Genuine backend savings come from the fixed 20Hz tick, AOI filtering, and chunked sparse masks (§5.3); room capacity planning must budget server geometry at the §5.2 rates rather than assuming the work has been delegated to untrusted hardware.
  • In-Process Cold Path (System.Threading.Channels): genuinely fire-and-forget work whose loss on restart is acceptable — verification/notification email dispatch, chi-square distribution auditing of RngKit roll history, and snapshot aggregation — rides in-memory Channels queues so it never blocks the hot path. Boundary rule: this queue is not where durable responsibilities live. Anything whose omission is observable (projections, Yjs flattening, archival sweeps, room lifecycle) belongs to the always-on Descent.Vtt.Worker (§7.1, ADR-021), because an in-process queue dies with its container and a scale-to-zero container is expected to die.
  • Email Dispatch: background stewards dispatch mail asynchronously via AWS SES (SMTP), never inline on an API controller or a SignalR hub thread. Delivery failures are retried with backoff and surfaced as a metric rather than swallowed.
  • Observability & Telemetry: OpenTelemetry + Serilog with TraceId correlation from ingress through Grain call to SQL, exported to a managed OTLP-compatible backend (a self-hosted Seq instance is itself an always-on stateful service and contradicts the serverless posture — acceptable for local development only, §10.2). A dedicated frontend crash pipeline intercepts WebGPUError and device-loss events (§9.5 Guardrail 1) and reports them together with the Session Diagnostic Envelope (§10.4) — hardware and driver identity is useless without the capability matrix that was active alongside it.

10.1.1 Infrastructure Provisioning & CI/CD Strategy

To strictly adhere to the FinOps (Cost-Optimized) and Serverless (Zero-Maintenance) philosophy for the SaaS environment, the following provisioning strategies are mandated:

  1. Compute Layer & CI/CD Delivery (Stateless Design)
    • Containerized Delivery: The .NET 10 backend is packaged as a standard OCI Docker image, ensuring absolute environment consistency.
    • CI/CD Pipeline: GitHub Actions (or Azure DevOps) is enforced for automated testing, building, and deployment. A single push automatically builds and pushes the image to a container registry (e.g., GHCR) and deploys it to Azure Container Apps.
    • Cross-Cloud Disaster Recovery — Honest Semantics (ADR-030): “Stateless containers” is true of durable data (Neon, R2) but not of cluster topology: Orleans silos hold live grain state, require a shared membership table, and require direct silo-to-silo TCP for grain calls. Two facts constrain the design and were previously glossed: (1) Cloud Run instances accept only HTTP ingress and are not individually addressable, so a multi-silo Orleans cluster cannot form there; (2) Garnet is VNet-internal to the ACA Environment, so Cloud Run silos cannot reach the SignalR backplane or the L2 cache at all. Deploying the same image to both clouds and switching DNS therefore does not yield an equivalent environment. The DR posture is stated explicitly instead:
      • Primary (ACA): multi-replica Orleans cluster with room-affine ingress (§8.3). Silo-to-silo reachability between replica IPs inside the VNet is a release-gating validation, not an assumption.
      • Fallback (Cloud Run): a deliberately single-instance, reduced-capacity mode with clustering and backplane dependencies disabled, a capped concurrent-room limit, and features requiring cross-silo coordination switched off. It is a business-continuity tier, not a hot mirror, and is labelled as such to users during failover.
      • Split-Brain Prevention: the two environments use distinct Orleans ClusterIds and failover is gated on a fencing token in the membership store, so both clouds can never own the same room. Never two live clusters sharing one membership table.
      • The Region Pair Is Declared and Asserted, Not Left to Deployment (ADR-083): this section reasons about DR as a capacity and topology problem and previously said nothing about geography, so an operator following it exactly could put the primary in one jurisdiction and the DR tier wherever Cloud Run capacity was convenient — and failover would then relocate live processing of all customer data automatically, at the worst possible moment, with no point at which anyone reviewed the decision. The primary and DR regions are therefore a declared pair whose jurisdictional co-location is asserted in the infrastructure definition, and a pair that crosses the declared jurisdiction fails to provision rather than warning. Crossing is permitted where an operator declares it deliberately, because for some operators it is the right trade against availability; what is not permitted is reaching it by omission. The constraint is a declared jurisdiction plus an assertion rather than a hardcoded region. Half of the original reason for that lapsed with ADR-108 — the platform used to be unable to know a self-hosted operator’s legal geography, and now knows its own — and the mechanism is kept anyway for the half that survives: a declared pair that is asserted fails to provision when it crosses, whereas a hardcoded constant is a value somebody can edit without review. Recorded rather than left implicit, so a future reader does not mistake a deliberate choice for a leftover. This says nothing about where backups and R2 objects live, which is the same class of question and is not addressed here.
      • Failover Is Validated, Not Assumed: an HTTP 200 health check cannot detect the actual failure mode here — a room whose players are split across silos with no backplane shows green while those players cannot see each other. Health checks therefore assert room-level liveness (“two sessions in one room exchange a tick”), and a scheduled cross-cloud game-day exercise is required for the DR claim to remain in this document.
  2. Microsoft Garnet Caching Strategy (ACA Environment VNet)
    • Internal Stateful Container: Since a managed Garnet DBaaS does not broadly exist yet, Garnet is deployed directly within the same Azure Container Apps Environment as the .NET backend. The ACA Environment provides a secure, internal Virtual Network (VNet). The backend communicates with the Garnet container via an internal IP, achieving ultra-low latency (<1ms) while keeping Garnet completely isolated from the public internet.
    • It Is Always-On and Must Not Be Load-Bearing for Correctness: Garnet runs at minReplicas: 1 and therefore never scales to zero — an acknowledged fixed cost (§10.2). It is also a single stateful replica, so its restart (patching, OOM, node move) is a routine event, not an outage-only scenario. Two rules follow: (1) no correctness property may depend on it — with room-affine delivery (§8.3) a Garnet restart degrades cross-room presence and cache hit rate but never partitions a room, whereas routing tick broadcasts through it would have made “everyone online but invisible to each other” a patch-window symptom; (2) cache contents are treated as reconstructible, since an ACA container has no durable disk and a restart starts cold.
  3. PostgreSQL 18 Database Strategy
    • Primary: Neon (Serverless Postgres). Traditional RDS incurs fixed monthly costs even when idle. Neon separates storage and compute, and during genuinely idle periods its compute scales down alongside the ACA containers.
    • Autosuspend Must Not Race Cluster Liveness: the Orleans membership table lives in this database, and §7.1 deliberately routes high-frequency play over WebRTC so it performs zero DB writes — meaning “players actively mid-combat” and “database has seen no traffic for ten minutes” is a designed-for steady state, not an edge case. If autosuspend fires between liveness publications, connections drop and a silo can be misjudged, evicting players mid-session. Therefore the branch hosting Orleans clustering has autosuspend disabled, Orleans liveness intervals and failure thresholds tolerate transient connection resets, and the always-on cost is accounted in §10.2.
    • Campaign-Branch Suspension Is Gated on Room Liveness, Not on Database Traffic (ADR-049). The claim that “campaign-data branches may still suspend freely” was false in both directions, and the reason is the same design property as above: this architecture deliberately makes active play produce no database traffic. If the always-on projection daemon (§7.1) polls the campaign branch, that branch never suspends and §10.2 silently under-counts an entire compute unit. If it does not poll, the branch suspends during an active session, and the first finalize after a quiet stretch pays a multi-second compute resume — which §5.1.1 previously compounded by sequencing the transform broadcast behind the event append, freezing the token for every other player for the duration. So:
      1. Suspension is driven by room-activation liveness. While at least one RoomGrain is active, the worker issues a lightweight keep-alive at an interval comfortably shorter than the autosuspend window. When all rooms deactivate and the projection backlog drains, keep-alive stops and the branch genuinely suspends.
      2. The activity signal travels via the clustering branch, which never suspends and already hosts the membership table. Silos publish room-activity counts there and the worker reads them from there — avoiding the circularity of querying a possibly-suspended branch to decide whether to let it suspend, and without making the worker an Orleans client.
      3. The daemon backs off adaptively (high-frequency with a backlog, long interval when idle, connection released when globally quiet), because otherwise the daemon is the keep-alive and rule 1’s savings never materialise.
      4. Keep-alive fails safe toward staying awake: if the activity signal is unavailable, the worker assumes rooms are active. Over-paying is recoverable; suspending mid-combat is not.
      5. CampaignBranchSuspendedWhileRoomsActive must be identically zero and alerts if not — this mechanism’s failure mode is silent, and its symptom appears somewhere else entirely.
      6. §10.2 states it precisely: campaign-branch compute is billed only while rooms are active. That is the genuine FinOps property and it is plannable; “suspends freely” was not.
  4. LiveKit SFU Strategy
    • Primary: LiveKit Cloud (Official Managed SaaS). Routing global audio/video requires complex edge network topologies. LiveKit Cloud offers a pay-as-you-go model that perfectly aligns with a serverless architecture, freeing the development team from managing TURN servers and edge nodes.
    • Fallback (Extreme FinOps): Bare-Metal Deployment. Should traffic scale massively, retaining the option to deploy the open-source LiveKit server on low-cost bare-metal servers (e.g., Hetzner, DigitalOcean) via Terraform remains a viable fallback to strictly control bandwidth costs.
  5. Infrastructure as Code (IaC)
    • Terraform / Azure Bicep: All infrastructure components (Cloudflare R2, ACA environments, Neon DBs) must be provisioned via IaC scripts. Manual configuration via cloud dashboards is strictly prohibited to guarantee reproducible environments.

10.2 FinOps & Architecture Comparison

Comparison Dimension🐙 Descent VTT (This Project)🏛️ Foundry VTT (Self-hosted/Forge)🌐 Roll20 (SaaS)
Compute Architecture.NET 10 Modular Monolith
Scale-to-Zero Serverless Container
Node.js (Single Thread)
Always-on VPS or Home PC
Cloud Monolith
Congested at Peak
Idle Server CostRequest-tier scales to $0; platform floor is non-zero (see note)$5 - $15 / Month (VPS fee)Subsumed in Subscription
Asset Egress Fees$0 / Month (Cloudflare R2)Depends on ProviderSubsumed in Subscription
A/V LoadLiveKit SFU (DataChannels/Media)Dependent on WebRTC / JitsiCentralized Relays (High Cost)
Search Engine OverheadPostgreSQL 18 JSONB + GIN
(no JVM ES; cold/warm SLO pair per §10.1)
Text files / NeDB scanServer-side DB scan
ObservabilityOpenTelemetry + Serilog → managed OTLP sinkStandard Console LogsBlack-box Internal

Idle-Cost Footnote (FinOps honesty): the ACA request tier genuinely scales to zero, which is the meaningful advantage over an always-on VPS. But this document’s own requirements imply an always-on floor, and quoting “$0/month” without it is not a number anyone can plan against. The floor comprises:

ComponentSourceNote
Descent.Vtt.Worker projection/lifecycle deployment§7.1, ADR-043minReplicas: 2 — one active, one standby. At one replica the platform pays for leader election and gets no failover
Garnet container§10.1.1minReplicas: 1; never load-bearing for correctness
Neon branch hosting Orleans clustering§10.1.1autosuspend disabled
Neon campaign branch§10.1.1, ADR-049billed only while rooms are active — a genuine and plannable property, unlike the withdrawn “suspends freely”
Edge Fetch Service§6.1, ADR-039small, outside the VNet, cannot be folded into the request tier
LiveKit service baseline§10.1.1relay bandwidth is metered separately (§10.3)
Observability sink§10.1(6)a self-hosted Seq instance is itself an always-on stateful service and contradicts the serverless posture; a managed OTLP-compatible sink is used instead, with Seq acceptable for local development
R2 storage§10.1(5)distinct from egress, which is genuinely $0
Sampled diagnostic retention§10.4(6)capped and TTL’d by design
(time-boxed) second cluster during an SDK deprecation window§3.1, ADR-055doubles the floor for that window; plannable, but not free

Long-lived play sessions also hold a WebSocket open for hours, so a populated room keeps its replica warm by definition. Per-room compute is far smaller than the per-stage ceilings in §5.2 suggest — those ceilings exist to isolate pathological rooms, and summing them describes a room that does not occur; capacity planning uses expected cost, dominated by viewer count rather than world size (§4.4).

10.3 FinOps Security & Economic Denial of Sustainability (EDoS) Defense

In a Serverless and Pay-as-you-go cloud-native architecture, mitigating Economic Denial of Sustainability (EDoS) attacks—where malicious actors attempt to run up astronomical cloud bills—is as critical as preventing data breaches. The system deploys strict defenses across six core vulnerabilities. Two were added after the enumeration was checked against the rest of this document rather than derived from first principles, which is the more useful observation: an EDoS inventory is only as complete as the list of expensive operations a user can trigger, and both additions are features that were designed as capabilities and never costed. Every face below must name a metric that rises when it is under attack — a release-checklist item, because a defence nobody can observe is indistinguishable from none.

  1. Asset Baking Pipeline: Maliciously uploading massive 3D models could trigger runaway ACA container billing. Defenses are placed at the point where cost is actually incurred: presigned URLs signed with a content-length-range ceiling, single-use and short-lived, issued only after a per-account quota, per-hour URL budget, and pending-upload check (§6.2) — a RoomGrain token bucket cannot help here, because direct-to-R2 upload happens before any Grain is involved. Plus a strict KEDA maxReplicaCount, per-account concurrent-job caps, and an R2 lifecycle rule reaping unclaimed objects. On billing controls: Azure Budgets are alerting-plus-automation, not a hard spend ceiling, so the enforcing control is an automated action group that disables the job environment on threshold breach, with per-account quotas as the primary defence and billing alerts as a backstop — the phrase “billing circuit breaker” overstates what the platform provides natively.

  2. Knowledge & AI Layer: Spamming LLM prompts causes Token API costs to explode. Defenses include embedding daily Token consumption quotas per room/player directly within the ILLMProvider abstraction, coupled with API rate limiting.

  3. LiveKit Edge Network (WebRTC Bandwidth): Modified clients broadcasting massive, meaningless WebRTC packets can cause severe egress bandwidth charges. Defenses involve strictly setting outbound bandwidth limits per participant during LiveKit Token issuance, automatically disconnecting abusers. Per-participant caps alone are insufficient: relay cost scales with fan-out, so a room of fully compliant participants can still be expensive (§9.6.3). Room-level aggregate relay budgets and the participant-count-adaptive publish rates in §9.6.3 are therefore part of the defence, and SFU relay bandwidth is a tracked unit-economics metric per session rather than an assumed-free channel.

  4. Event Sourcing Database (Marten Event Store): Spamming meaningless state mutations (e.g., dropping/picking up an item 100x a second) bloats storage. Defenses include strict throttling mechanisms at both the frontend CQRS command queue and the backend RoomGrain to intercept spam mutations. Note that the per-player intent rate cap is not a free parameter: it is derived from the mailbox jitter budget in §5.2 Table A, so loosening it for throughput trades directly against tick stability.

  5. Edge Fetch Service (§6.1, ADR-039): a player-named URL costs egress bandwidth, an R2 write, and decode CPU before any Grain is involved, so none of the §6.2 upload quotas apply — they are attached to presigned-URL issuance and this path does not use one. Two hundred pasted links to large files is a bill, not a bug report. Defences: per-account token buckets on fetches/hour, bytes/day and concurrency; hard streamed-byte and wall-clock ceilings independent of a declared Content-Length; decoded-pixel-count limits in the quarantined decode step; and an R2 lifecycle rule reaping quarantine objects.

  6. Replay Export & Timeline Checkout (§7.3, ADR-047, ADR-059): both are unbounded event replays that a user can trigger from a normal UI affordance. An export re-runs a whole campaign through the per-viewer visibility filter — tens of seconds of CPU for a large campaign — and served from the request tier, a post-convention rush of export requests competes directly with live rooms, while KEDA’s response (more empty silos) does not help an occupied one. A checkout is the same shape, plus N per-viewer Full Snapshots, and a Keeper scrolling the node tree issues them several per second. Defences: export runs in the offline Jobs pool with per-account concurrency and daily caps (Q-034), delivered via short-lived R2 links; checkout runs as an out-of-mailbox saga with a per-room token bucket, UI debounce so only the final selection executes, and a per-account cap on concurrent checkouts across rooms — because one account scripting tree-scrubbing across fifty rooms is otherwise pure amplification (Q-049).

    These bounds contain cost; they cannot separate abuse from use, and Q-034 / Q-049 stay pending for that reason (ADR-087). A convention organiser, a play-by-post organiser with fifty rooms, and an account systematically scraping campaigns produce the same telemetry. Set the bound where the organiser needs it and the scraper is unbounded; set it where the scraper stops and the organiser breaks mid-event. The blocker is therefore the product’s entitlement-tier model, not measurement — a quota bounding legitimate high volume and abuse with one number encodes a compromise in a constant nobody will later recognise as a compromise. Cost containment does not wait for it: the existing per-account bounds continue to protect the bill, which is what they were designed for. Where a bound must exist before the tier model does, it is set to protect cost and is labelled that way in Quantity_Registry.md, because a bound doing one job must not be documented as doing two.

10.4 Day-2 Operability: Diagnosability, Rollback & Incident Reconstruction (ADR-031)

Event Sourcing makes the authoritative history perfectly auditable, which is necessary but not sufficient: in this architecture the data that determines what a player actually saw — client-predicted geometry, the active capability matrix, WebRTC ephemeral traffic, the visibility allowlist version, projection lag — never reaches the server. Without the following, the archetypal support ticket (“three of us saw the door closed, three saw it open, for ten minutes”) is un-debuggable, because the event stream will correctly show the door opened once and nothing else is recoverable. This section is a hard requirement, not aspirational tooling.

  1. Session Diagnostic Envelope. Every client attaches to telemetry and error reports: the resolved Profile and Guardrail 7 capability matrix result, the Descent.Geometry crate revision, protocolVersion and build id, the highest applied snapshot tick and DurableSeq, interpolation buffer depth, reconciliation and RingOverflow counts, lease acquisitions/expiries, the observed client-vs-server tick offset (§5.1.1 depends on tick sequence rather than wall clock, and a skew here is otherwise invisible), MaskStaleness (§5.2), the active Yjs transport mode — SFU or SignalR relay (§9.3) — the degradation-ladder step in force (§5.3), and per-plugin budget events (§9.5 Guardrail 5). These are the variables that differ between two players in the same room; without them a divergence report cannot even be classified. Several were added because a mechanism was introduced with no way for an operator to see which path a given player was on, and an unobservable degraded path is one that never gets fixed.
  2. Per-Viewer Replication Digest — and its client-side counterpart (ADR-057). For a sampled window (and on demand for a flagged room), the server retains a rolling hash of what it sent each viewer per tick.
    • A comparison needs two digests, and only one was specified. “Viewer B’s digest diverges from the server’s at tick N” is the right operator experience, but the client side of §10.4(1) carried only counters and a highest-applied tick — nothing to compare against. Worse, this section’s own opening states that the data determining what a player actually saw never reaches the server, so a server-only digest would have shown all six viewers receiving the same correct DoorOpened delta and explained nothing about the archetypal ticket. Clients therefore publish a per-tick applied-state rolling hash (entity set, positions, visibility mask hash) within the sampled window.
    • It must cover predicted and authoritative state separately. A digest of only the final rendered state cannot distinguish a lost packet from a prediction that diverged because the client was legitimately not told about an occluder — and that class (§9.2, ADR-034) is the most likely cause of two players seeing different worlds.
    • Operator tooling must classify, not just detect: given (roomId, tick, viewerId), it returns the server-sent digest, the client-applied digest, and a classification — lost packet / prediction divergence / projection lag / capability difference — without requiring the session to be reproduced.
    • Explicitly not an anti-cheat signal. Legitimate capability differences produce divergences, so a digest mismatch must never be the sole basis for a sanction. It is a diagnostic with false positives by design.
  3. Room Time-Machine for Operators. The GM-facing branch/undo UI (§7.3) is a gameplay feature, not an operations tool. Operators additionally require: replay of a room to any tick as a specific viewer, a diff of two ticks, and read-only inspection of an archived room without re-attaching its partition into the hot path.
  4. 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 bad rule-plugin release → cartridge version pinning per room plus a compensating-event mechanism authored and applied by operators (distinct from the GM’s Undo, and itself auditable); a bad projection → versioned projections rebuilt side-by-side into a new read model and atomically swapped, which stays possible for every room precisely because events are never deleted (ADR-019); a bad schema migration → forward-only migrations with an expand/contract sequence, since a partially-applied destructive migration cannot be reversed against a live event store.
  5. Privileged Actions Are Recorded, and That Is an Audit Trail Rather Than a Detection System (ADR-088). Export — including which channels it declassified (ADR-084) — erasure requests, cartridge admission, and membership changes once ADR-085’s model exists, are recorded as domain events under ADR-069’s semantics. They carry only a SubjectId and never a personal attribute (ADR-078). This makes a compromise investigable after the fact and stops nothing at the time, which is stated because the opposite claim is the error this document has already had to correct three times. Deliberately not the §10.4(1) diagnostic envelope under §10.4(6)‘s retention policy: that data is sampled, capped and TTL’d by design, which is right for diagnostics and precisely wrong for an audit trail. Authentication mechanism design is out of scope for this document — §13 owns its delivery, the design belongs in a security specification this document does not contain — and the boundary is stated because an unexplained silence leaves a reader unable to tell whether authentication was placed elsewhere or never addressed. One tension is left open rather than resolved: the audit trail is itself retained personal data about the acting principal, so ADR-079’s rights-holder question applies to it, and the answer conflicts with an audit trail’s purpose.
  6. Cost-Bounded Retention. Diagnostic data is sampled, capped, and TTL’d by design, and its retention appears in the FinOps floor (§10.2) rather than being discovered as an unbudgeted line item.

11. Architecture Decision Records (ADR Governance)

ADR Record Format (mandatory): the list below is an index. Each ADR is maintained as a full record containing Status (Proposed / Accepted / Superseded-by), Date, Context, Alternatives Considered and Why Rejected, Consequences (including the negative ones), Enforcement (see ADR-045), Rights-holders where the decision retains a new class of data (ADR-079 — naming “nobody” is an acceptable answer, leaving the field blank is not), and Supersedes / Superseded-By / Amends / Amended-By links. Titles alone are what previously allowed two mutually exclusive UGC isolation models to coexist in this document with no record of which decision was newer; a decision without a recorded status cannot be adjudicated later.

Amends exists because supersession is too blunt (ADR-045). Several decisions narrow a single clause of an ADR that remains correct overall. Recording those as supersession would retire a decision that is still right; recording them nowhere loses them. Amends is that third state, and an amended ADR keeps its Accepted status.

Depends-on exists because Amends was still too blunt (ADR-077). Four link types now exist, and they differ in the obligation they create — which is the only reason to distinguish them:

LinkMeaningObligation on the target
SupersedesThe target is retiredTarget’s status changes; its diagnosis is normally retained
AmendsOne clause of the target is narrowed; the target stays correct and AcceptedTarget records the reverse link
Depends-onAn outcome this ADR asserts is implemented by a mechanism the target governs. The target is unchanged — but it now carries an obligation it may not know aboutRevising the target requires listing its Depended-on-by set and confirming each named dependency still holds
interacts withInformational onlyNone — and it must never be used where an obligation exists

A Depends-on link must name the dependency (Depends-on 049 (liveness quantity), not a bare Depends-on 049); an unnamed dependency cannot be evaluated at revision time, which is the only thing the link is for. Phase 3 found seven of these, none of which Amends could have expressed — ADR-067 asserted a branch would suspend while the quantity deciding it lived in ADR-049, and both were correct. The honest limit: a missing Amends fails the reverse-link lint, whereas a missing Depends-on has no symptom at all. Its completeness rests on review habit, not on CI, and it is recorded that way rather than presented as a guarantee.

A decision requires an ADR when it (a) contradicts or narrows an existing ADR, or (b) rejects an alternative a future reader would plausibly re-propose. Everything else is a section-level directive. Applying this criterion retroactively promoted four directives into ADR-058…061 — which is the evidence that the criterion earns its cost.

Index status legend: Accepted · Superseded-by N (retired) · Amended-by N (still Accepted, one clause narrowed) · Conditional (in force only if a named condition holds).

Where the full records live: the table in docs/README.md, under “ADR records — the full record for every decision”. One copy, there rather than here — this section previously carried its own copy of that table, and the copy drifted: it silently stopped at 109 and had no rows for 091–098 while the README’s copy was being maintained (found 2026-08-08; owner ruling: delete the copy, keep the pointer — the platform’s cite-never-copy rule applied to this document’s own tables). An index that does not say where its records are is an index nobody consults; an index that keeps a private copy of the answer stops consulting the authority (P6).

Every quantity in this document is registered in Quantity_Registry.md under a Q-ID (ADR-045). That file is the single source; a number appearing here and disagreeing with it is a defect in this document, not a second opinion. Entries marked are derived from assumed unit costs and are not normative until their benchmark lands — a specific falsifiable claim published as such, rather than an unfalsifiable allocation.

ADRTitleStatusLinks
001Strict Length Truncation & AST Lockouts for DoS MitigationAccepted
002Modular Monolith Architecture & Rejection of Microservices/K8sAcceptedclarified by 062; Depended-on-by 173
003Single Authoritative World Model & Rendering ProfilesAccepted
004Hybrid Serialization Strategy (FlatBuffers 0-GC & MessagePack)AcceptedAmended-by 058
005PixiJS as a Profile C Adapter over baked 2D tiles, not a projected 3D sceneAcceptedAmended-by 066; interacts with 061; Depended-on-by 158; Amended-by 166 (which adds a presentation surface above this ladder and does not implement Profile C’s renderer); Depended-on-by 171
006Modular Asset Bundles & Presigned URL PipelineAcceptedAmended-by 123
007Simulation Authority Boundary & Intent Command PatternAcceptedAmended-by 089
008Event Sourcing & CQRS via Marten for Domain StateAcceptedAmended-by 069, 078
009Virtual Actor Model (Microsoft Orleans) for Concurrency & Room ManagementAccepted
010Zero-DOM UGC Sandboxing (WASM/QuickJS Declarative UI vs. Iframes)AcceptedAmended-by 039, 040, 068, 086, 116, 117; Depended-on-by 098
011Offline Synchronization via CQRS Command Queue & Server ReconciliationAcceptedAmended-by 051; Depended-on-by 054
012SFU Architecture (LiveKit) vs. P2P Mesh for High-Frequency Ephemeral DataAcceptedAmended-by 050, 081, 167
013Hybrid UI Rendering Boundary (World-Space WebGPU vs. Screen-Space DOM)AcceptedAmended-by 053; interacts with 061
014Server-Issued Minimized Outbound Allowlist & Targeted WebRTC RoutingAcceptedAmended-by 038, 050
015EDoS Defense in Depth StrategyAcceptedAmended-by 039, 059, 087, 117
016State Authority Tiers (T0 / T1 / T2) & Ephemeral Ownership LeasesAcceptedAmended-by 035, 037, 050; Depended-on-by 160
017One Geometry Implementation (Descent.Geometry Rust crate, fixed-point, dual-hosted)AcceptedAmended-by 034, 052, 056; Depended-on-by 091; Depended-on-by 127; Depended-on-by 129; Depended-on-by 143 (the single-implementation rule a wasm-only geometry host would have strengthened, declined on Q-015a); Depended-on-by 155; Depended-on-by 171
018Grain Mailbox DisciplineSuperseded-by 033
019Cold Data via PostgreSQL Table Partitioning instead of delete-to-object-storageSuperseded-by 036core reasoning retained in 036
020Branch as a First-Class Data Dimension & Assembly-Independent UpcastingAcceptedAmended-by 035, 042, 047, 055, 069, 070, 071, 076, 135
021Dedicated always-on Projection & Lifecycle Worker with single-writer leadershipSuperseded-by 043
022Replay/Export Archives are server-generated, per-viewer filtered, reduced state deltasAcceptedAmended-by 059, 060, 084; Depended-on-by 104
023AOI Entry Requires a Per-Entity Baseline; every “Full Snapshot” is per-viewer filteredAcceptedsatisfied for leased entities by 050; Depended-on-by 177
024Room-Affine Ingress for tick broadcastsSuperseded-by 032diagnosis retained in 032
025Lossy-by-Contract Presentation Ring with an explicit drop policyAccepted
026Mandatory Server Relay Fallback for YjsAcceptedAmended-by 080, 085; relay host, capability-token authorization and rate limits are Consequences of this record; Depended-on-by 104
027The Capability × Consumer Matrix as a release gate (no blank cells)AcceptedAmended-by 061, 064, 068, 101; interacts with 103
028Result-First Deterministic Dice (result + shared physics seed)Superseded-by 041diagnosis retained in 041
029SignalR/WebSockets as the production transport baselineAcceptedinteracts with 090; Depended-on-by 092; Depended-on-by 130 (SignalR-over-WebSockets as the only supported transport is what makes one ticket redemption per connection true); Amended-by 138 (its Phase 5 WebTransport evaluation closes negative; the transport baseline itself is unchanged)
030Cross-Cloud DR as an explicitly reduced-capacity tierAcceptedAmended-by 055, 083
031Day-2 Operability RequirementsAcceptedAmended-by 057, 088
032Targeted per-silo forwarding for tick delivery over a non-persisted room connection registry; room-affine ingress demoted to a latency optimisation no correctness property may depend onAcceptedSupersedes 024; Amended-by 090, 062 (conditional)
033Pipelined tick — geometry dispatched fire-and-forget and consumed one tick later; snapshots carry transformTick and maskTick; no Grain method may await a dedicated pool from inside the mailbox, the tick includedAcceptedSupersedes 018; Amended-by 046, 047; Depended-on-by 132, 134 (the mailbox discipline that makes the Tier A check and the cartridge load dispatched sagas rather than awaited activation steps); Depended-on-by 142 (the 20 Hz tick body is why TieredCompilation is kept on under ReadyToRun); Depended-on-by 164; Depended-on-by 170; Depended-on-by 177
034Client geometry predicts own-entity movement over disclosed geometry only; client LOS/FOW is presentation-only; correction timing is scheduled so it carries no informationAcceptedAmends 017; Amended-by 052, 091; Depended-on-by 094; interacts with 101; Depended-on-by 155; Depended-on-by 156; Depended-on-by 167
035Explored FOW masks are persisted, branch-keyed, copy-on-write T0 chunk state written in the snapshot transactionAcceptedAmends 016, 020; Amended-by 048; Depends-on 036 (room-scoped relocation of chunk rows); Depended-on-by 091
036Event archival is room-scoped over immutable hash(room_id) partitions, executed offline with verification and preserved seq_id; projections must be idempotent per (stream, version)Accepted (mechanism pending spike)Supersedes 019; Amended-by 078; Depended-on-by 035
037A snapshot’s SourceEventSeq may never exceed the committed sequence; cache-resident snapshots are seq-validated read-through copies of T1AcceptedAmends 016
038Ephemeral payloads may contain only the sender’s input-derived values and transforms of leased entities — never a value computed from the sender’s occluder or visibility setAcceptedAmends 014; Amended-by 091
039Player-supplied external input is trust tier P4; server-side fetching runs in an identity-less, VNet-isolated Edge Fetch Service with resolved-address admission control and quarantined decodeSuperseded-by 117Amends 010, 015; Superseded-by 117 — which retains every control in this record and narrows only who may invoke it. The diagnosis is not retired: the SSRF analysis here is the reason a fetcher may never be reintroduced casually
040Client plugins run one QuickJS runtime per plugin under interrupt-driven budgets, with the RPC bridge prioritised above plugin executionAccepted (preemption pending spike)Amends 010; Amended-by 086; Depended-on-by 098; Amended-by 144 (its clause 1 QuickJS runtime becomes a wasm32 guest on the silo’s three-name ABI), Amended-by 146 (Spike S6 closed: clause 2’s interrupt is unimplementable in a browser, the hard ceiling becomes Worker.terminate(), and one-worker-per-plugin is reinstated on measurement); Amended-by 151; Amended-by 165
041Dice are result-first via a CI-verified offline trajectory table with non-overlapping tray slots; no seed inversion, no runtime physics solveAcceptedSupersedes 028; Depended-on-by 059
042Every derived artefact declares exactly one Scope — Campaign / TimelineIndependent / Licence / Global — as separate typed repository surfacesAcceptedAmends 020; Amended-by 080, 082; Depended-on-by 078, 097; Depended-on-by 122; Depended-on-by 128
043Projections run on a minReplicas: 2 worker with per-shard leases aligned to the event partitions; rebuilds are shard-scoped and reject edits behind the rebuild watermarkAccepted (mechanism pending spike)Supersedes 021; Amended-by 082; Depended-on-by 078; Depended-on-by 161; Depended-on-by 168
044Commit–reveal verifiability is scoped to a public byte stream plus an auditable data record of inputs, thresholds and outputs — never the evaluation code; a shipped verifier is the condition of the claimAccepted
045Documentation governance: Amends link type, the enforcement-point rule, and the quantity registryAcceptedAmended-by 077, 079, 106; Depended-on-by 178 (its clause-2 criterion is what every clause of that record is written against — and its FOURTH enforcement option, a named release-checklist item, had no artefact behind it in thirty sentences until R44 created one)
046Script Effects carry a seam-recorded premise set; a mismatch invalidates the whole Effect buffer atomically and is reported to author and playerConditional — full form in force only once field-level version granularity exists; entity-level premises until then (ADR-074)Amends 033; Amended-by 074; Depends-on §8.2 item 6 (field-level substrate); Depended-on-by 094, 095; Depended-on-by 164; Amended-by 177
047Timeline checkout runs as an out-of-mailbox saga with atomic swap, rate-limited and debounced; node-tree browsing reads T2 rather than replayingAcceptedAmends 020, 033; Amended-by 070, 075, 087; Depended-on-by 070
048Resident visibility memory is one budget, not four independent ceilings; visibility sets are grouped with sparse per-player deltas; breach follows an ordered degradation ladder before any refusalAcceptedAmends 035; Amended-by 064, 073
049Campaign-branch autosuspend is gated on room-activation liveness, not database traffic; keep-alive fails safe toward staying awakeAcceptedAmended-by 067, 072; Depended-on-by 067
050Lease timing in tick sequence numbers; grants pushed immediately with grace-buffered peer packets; handback guarantees visual continuity by decoupling T0 resume from durability; staleAnchor; coalesced advisory positions for AOI/allowlistAcceptedAmends 016, 014, 012; Amended-by 067; Depended-on-by 126, 129; Depended-on-by 131 (the per-connection lease identity that a durable ViewerId would collide on)
051Offline Intent eligibility is a fail-closed whitelist of non-spatial actions; offline spatial movement is a local planning layer; long-lived offline leases are forbiddenAcceptedAmends 011; Amended-by 067
052The geometry crate runs in a single worker with private linear memory and transferable I/O; the shared WASM arena, the +atomics build and the render-worker Atomics.wait are retiredAcceptedAmends 017, 034; Amended-by 096
053The Network Worker is the single arrival authority and fans out to a DOM-facing channel and the Render Worker’s ECS. One latch, two sourcesAcceptedAmends 013; Amended-by 102; Depended-on-by 125 (the main-thread network prohibition, composed with §9.3’s query cache); interacts with 089, 101
054Build-id keyed code caches, app shell never Cache-First, pre-handshake version endpoint, bounded forced update that first flushes offline Intents and unmerged YjsAcceptedDepends-on 011 (Timeline Rule may batch-reject the flushed queue); interacts with §9.5 Guardrail 6; Depended-on-by 130 (the Edge-Hold ladder’s retry count and the distinct-wake-state rule, which force per-attempt ticket acquisition and two new phases)
055Cartridge-set SDK-major compatibility is admitted per room before a host upgrade; incompatible rooms pin to a separately-ClusterId’d environment and expire to ruleset read-only, never a failed activationAcceptedAmends 020, 030; Amended-by 071; Depended-on-by 095, 134
056The parity corpus verifies correctness against an independent high-precision oracle (test-only), not merely native/WASM agreementAccepted (details pending spike)Amends 017; Amended-by 065
057Clients publish a per-tick applied-state digest covering predicted and authoritative state separately; operator tooling classifies a divergence without reproducing the sessionAcceptedAmends 031
058Room snapshots in cache are byte-identical copies of the T1 payload — there is no second serialization shapeAcceptedAmends 004; Amended-by 076; Depends-on §10.1 #3 (persisted snapshot format); interacts with 092
059Campaign export is an offline job recording the geometry crate and cartridge versions it resolved with; EDoS face 6AcceptedAmends 022, 015; Amended-by 087; Depends-on 041 (trajectory-table version must be in the recorded set)
060Content keys are session-scoped and never persisted, so revocation works; protected bundles are consequently unavailable in network-detached replayAcceptedAmends 022; Depended-on-by 119, Depended-on-by 153
061Presentation effects target cross-profile semantic parity, not visual parity, as a blank-blocks-release row in the Guardrail 7 matrixAcceptedAmends 027; Amended-by 103; interacts with 005, 013
062One silo per ACA app so each silo has a stable internal FQDN; an explicit room→silo assignment table is written by the placement director and read by the edge routerConditional — in force only if silo-to-silo pod addressing proves unavailableAmends 032; clarifies 002
063Verification is tiered by available determinism; pixel comparison of the canvas is rejected as a release gate and replaced by a deterministic draw-submission digest, with perceptual golden images demoted to a change detector on a pinned software adapterAcceptedinteracts with 061, 057
064A compiled-out test-only capability veto and budget/fault injection seam is a first-class component; every Guardrail 7 cell and every degradation ladder must be demonstrated by a passing test, and a cell without one is treated as blankAcceptedAmends 027, 048; Depended-on-by 124, Depended-on-by 153; Depended-on-by 156; Depended-on-by 157; Depended-on-by 166
065Recorded session event logs are the netcode and upcaster regression corpus, verified against the same ADR-057 digest used in production; golden expectations may only change in a separately reviewed commit, and the ADR-056 oracle may never be regenerated from the implementationAcceptedAmends 056
066Profile C splits by viewport role into C-Tactical and C-Companion, each with its own layout and Guardrail 7 column; a single continuously responsive layout down to phone width is rejected; the GM role and all authoring surfaces are declared unsupported on C-Companion rather than degradedAcceptedAmends 005
067A backgrounded client releases every held lease immediately on visibilitychange and ceases to count as room-activation liveness; return to foreground is a per-viewer resync, never a data-loss eventAcceptedAmends 049, 050, 051; Amended-by 072; Depends-on 049 (liveness quantity)
068UI descriptors declare semantic role and priority per field group so the renderer performs profile-specific layout; registration-time validation refuses a descriptor with no viable C-Companion rendering, on the same principle that refuses a bundle with no 2D bakeAcceptedAmends 010, 027; parallel to 005; interacts with 103
069Domain events record adjudicated outcomes with their inputs attached as evidence, never as instructions; the rules engine is never re-run over history, so projection rebuild replays outcomes and never re-adjudicates decisionsAcceptedAmends 008, 020; Depended-on-by 078
070State migrations are a declarative artefact distinct from event upcasters; a timeline checkout crossing a cartridge-major boundary runs one, previews its diff, and forks rather than mutating — and is refused, naming the blocking cartridge, where none is declaredAcceptedAmends 020, 047; Amended-by 075; Depends-on 047 (command-refusal window semantics)
071Attribute definitions carry a retired tombstone state; attribute removal is a two-release deprecation; a campaign pins its cartridge major and opts in to upgrades; removal is refused at publish time against a reverse-dependency index over formulas, macros and SDK consumersAcceptedAmends 020, 055; Amended-by 095
072Room liveness is three states — Active / Dormant / Deactivated. A Dormant room (no foregrounded client, no pending work, after a grace window) suspends its tick rather than slowing it, because a room with zero viewers has nothing to simulate; an unknown foreground signal counts as foregrounded. Dormant describes a room that loaded: a room whose hydration was abandoned is a separate state that releases its activation and is metered separately (amended 2026-08-02)AcceptedAmends 049, 067; Depended-on-by 123
073No rung of the degradation ladder may widen any viewer’s disclosure set: delta collapse discards the delta rather than merging it, subtractive deltas are ineligible for collapse, and coarsening resolves by AND rather than ORAcceptedAmends 048; interacts with 084, 091
074ADR-046 is Conditional on field-level version granularity; until then premises are entity-level with atomicity unchanged, and the field-level substrate is one owned workstream serving both the premise versions and §8.2’s per-field visibilityAcceptedAmends 046; assigns an owner to §8.2 item 6
075Checkout is build-then-commit: the candidate state and its diff are built entirely outside the command-refusal window with the room fully playable, and the room refuses commands only for the atomic swap; a candidate whose room advanced meanwhile is invalidated, never silently swappedAcceptedAmends 047, 070
076T0 snapshots are never upcast — a SnapshotSchemaVersion mismatch discards and replays — but a schema change is a declared migration whose new snapshots are pre-generated shard-scoped before the version goes live; cache keys include the schema versionAcceptedAmends 058, 020; Depended-on-by 078
077Depends-on / Depended-on-by is a fourth link type for “an outcome this ADR asserts is implemented by a mechanism the target governs”; the dependency must be named, and revising an ADR requires confirming every dependency registered against itAcceptedAmends 045
078Domain events, T0 snapshots and T1 payloads carry opaque identifiers only; the identity linkage is a Global-scoped record outside the event store and subject erasure is deletion of that record, so the never-delete invariant is preserved by construction; personal attributes inside events are declared exceptions held as per-subject ciphertextAcceptedAmends 008, 036; Depends-on 069 (history is never re-adjudicated, so an evidence field is never a replay input); Depends-on 043 (shard-scoped rebuild is how plaintext leaves T2); Depends-on 076 (discard-and-replay is how a shredded subject leaves T0 snapshot state); Depends-on 042 (Global and TimelineIndependent are branch-agnostic, so undo, fork and checkout cannot resurrect an erased record); Depended-on-by 080; Depended-on-by 130 (the erasure-by-construction property that forbids deriving a ViewerId from the Marketplace subject); Depended-on-by 131 (the stored, never-computed linkage that makes erasure a DELETE of one row); Depended-on-by 132 (erasing a subject locks the rooms they own — the gate can no longer resolve the owner)
079An ADR introducing a new class of retained data must name who can claim on that data besides the platform, and what honouring the claim would require; “nobody” is an acceptable recorded answerAcceptedAmends 045
080Yjs erasure is item-granular — a co-authored document loses the subject’s items and retains the rest, a wholly subject-authored document is deleted with its DocId reference; the erasure is a CRDT delete carried on both the SFU and the ADR-026 relay, never an out-of-band blob mutation, and the flattened pg_trgm search text is purged in the same jobAcceptedAmends 026, 042; Depends-on 078 (authorship metadata is identity linkage and erases with it); Depended-on-by 085
081No audio or video is persisted by the platform, as an invariant rather than a side effect of §7.1’s write-avoidance; enforced in the SFU deployment because media never enters the .NET process. Explicitly not extended to the Self-Hosted profile where the SFU is operator-runthat exemption is deleted by ADR-108, which removed the profile; the invariant is unconditional across every supported deployment. It still does not cover participants’ own local captureAcceptedAmends 012; Amended-by 108; interacts with 022
082An account is not an infrastructure tenant — one store, one schema, Row-Level Security rejected because ADR-043’s worker legitimately reads across accounts; isolation is ADR-042’s typed scope surfaces plus an entitlement context without which a Licence-scoped surface cannot be constructedAcceptedAmends 042, 043; Amended-by 097
083Primary and DR regions are a declared pair whose jurisdictional co-location is asserted in IaC; a crossing pair fails to provision rather than warning, and is permitted only where the operator declares it deliberately. Mechanism unchanged by ADR-108; half its rationale retired — the “an operator’s geography is unknowable” argument lapsed with the Self-Hosted profile, and the declared pair is kept because it fails to provision where a hardcoded constant is merely editableAcceptedAmends 030; Amended-by 108
084The ADR-022 declassify toggle is scoped to concealment the GM authored — GM and Secret channels — and may never widen disclosure of content another participant authored, which is what §8.2’s Player Channel carries; a declassified export records which channels it liftedAcceptedAmends 022; interacts with 073
085Departure and erasure are two verbs and must never be one operation; a departed participant’s authored content remains unless they separately request erasure, and whatever membership comes to mean, revocation must reach ADR-026’s document-write capability token and export entitlement — and, since ADR-123, a third path: presigned asset-read URL issuance, which had the same stale-authorisation shape and was not on the listAcceptedAmends 026; Amended-by 104, 123; Depends-on 080 (item-granular erasure is the only content-removal path, so departure must not reuse it); Amended-by 132 (its openness clause is closed by the room membership model; its departure/erasure ruling is untouched and still in force)
086A plugin’s interest set may never exceed the disclosure set of the principal who admitted it, evaluated against §8.2’s channels rather than a second permission model; §6.4 states that it classifies extensions and not principals, and Q-027 becomes a resource bound rather than a disclosure boundAcceptedAmends 010, 040; Depended-on-by 116
087Q-034 and Q-049 stay pending with the product’s entitlement-tier model named as the blocker rather than measurement — a quota bounding legitimate high volume and abuse with one number encodes a compromise in a constant nobody will later recognise as oneAcceptedAmends 015, 047, 059; Amended-by 105
088Authentication mechanism design is out of scope and the boundary is stated rather than left as an absence; privileged account actions are recorded as domain events carrying only a SubjectId — an audit trail, not a detection system, and itself retained data to which ADR-079’s question appliesAcceptedAmends 031
089The §5.1 renderer read-only boundary is enforced by interface segregation — renderers receive IReadOnlyWorldState or immutable structures — plus NetArchTest on the .NET side and a dependency-cruiser assertion in §14.4’s list on the frontend side, because the renderers it names are frontend TypeScript a .NET test cannot reachAcceptedAmends 007; interacts with 053
090The §8.3 backplane prohibition is enforced against the broadcast API rather than the payload type — a Hub carrying snapshots to clients is the correct implementation, so a payload-type scan would fail the build on correct code; a runtime SLO asserts backplane publish rate does not scale with tick rate × room count, and Q-056 bounds message size as defence in depthAcceptedAmends 032; Amended-by 092; interacts with 029
091Intentional leakage is a server-side mechanic — audibility and leaked illumination are server-computed and dispatched pre-attenuated at the aperture, never filtered client-side; permeability is two fixed-point occluder parameters whose partial-set conclusion is a lower bound only (the third disclosure asymmetry); leaked values are quantised and rate-bounded and their product is the channel capacity; NPCs get LOS rays and a last-known-position scalar, never mask setsAcceptedAmends 034, 038; Depends-on 017 (fixed-point determinism contract); Depends-on 035 (explored-chunk persistence model); Depended-on-by 094; Depended-on-by 167; interacts with 073
092The tick-driven snapshot carries transform state only — cartridge attributes and the Q-010 fog mask each travel as their own event-driven message, because embedding them costs 128× and 50× respectively at 20Hz; flatc is the sole wire compiler (FlatSharp refused for emitting C# only), the NuGet runtime pins the compiler version, generated sources are never committed, and Q-056’s 32,768 is one decision with the payload rule rather than an independent capAcceptedAmends 090; Depends-on 029 (SignalR is the transport whose message size this bounds); Depended-on-by 102; interacts with 058; Depended-on-by 126; Depended-on-by 154

| 094 | Opposed rolls resolve entirely server-side and no client is sent an opposition’s rating, threshold or unresolved roll; what the result discloses is a GM-delegated, room-scoped policy defaulting to net outcome only, because the risk being managed is accidental disclosure; declassification is a §9.2 keeper authority a player may request and never set and a cartridge may read and never change, recorded in the stream so a replay can tell an open roll from a closed one; the net outcome remains a channel whose rate is bounded by Q-076, coupled to the outcome vocabulary as capacity is log₂(outcomes) × rate | Accepted | Depends-on 034 (visibility has one producer, and it is the server); Depends-on 046 (premise validation); Depends-on 091 (the disclosure asymmetries, and the precedent that a leak can be a mechanic); interacts with 017, 071; Depended-on-by 116 | | 095 | A vocabulary may be open at its edges — a declaration may name a key or open a prefix family, so a player-invented specialty is writable without being enumerable, at the cost of the tombstone inside a live family, which is stated rather than discovered; keys are bounded at Q-077’s 64 bytes because a family makes the suffix caller-supplied; a skill catalog is composed from Core + Scenario + GM Custom tiers where no tier may remove; an unrecognised key is preserved on hydration, refused on mutation and resolution, and surfaced to the keeper as an orphan; a keeper authorises a declaration into a room as an event on its own stream | Accepted | Amends 071; Depends-on 046 (premise validation); Depends-on 055 (cartridge major pinning); interacts with 020, 037 | | 096 | Cross-origin isolation is retained for the game route and recorded as a posture rather than inferred from three sections, because two documents in the corpus implied different answers within a week; nothing may be built that depends on it without its own ADR stating the isolation-absent behaviour, since after ADR-052 the only consumers are two main-thread channels and the cost of absence is one frame of skew; and no external bounded context may reason about this posture | Accepted | Amends 052; interacts with 034; closes OI-M-01; Depended-on-by 157; Depended-on-by 166 | | 097 | vtt_entitlement_v1 is admitted to Global scope as a single read-only entitlement surface whose writer is another bounded context — a case ADR-082’s taxonomy did not contemplate — reached through a typed surface that cannot be constructed without an entitlement context; a Marketplace-pushed event replica was rejected because it converts a 500 ms consistency window into an unbounded revocation window; entitlement is authorisation input and never disclosure authority | Accepted | Amends 082; Depends-on 042 (typed scope surfaces); interacts with 086; closes OI-M-02; Depended-on-by 120; Amended-by 130 (the ticket half of the seam is built; clause 3’s contract test is restated as still owed); Depended-on-by 131 (the typed entitlement surface, the bounded fresh read, and clause 3’s contract test); Depended-on-by 132 (the entitlement surface both tiers read through, and clause 4’s separation of authorisation from disclosure); Depended-on-by 168 | | 098 | Descent Studio is a local fat client and an untrusted producer — every check it runs is advisory and is re-run server-side at ingestion under authoritative quantities; cryptographic signing establishes provenance and never safety, so a correctly-signed bundle exceeding Q-017 is still rejected; trusting attested builds or verified publishers was rejected because it converts key theft into a permanent bypass of every §4.3 resource bound | Accepted | Depends-on 010 (JSON AST is the only delivery format); Depends-on 040 (plugin resource budgets); interacts with 055, 060, 095; Depended-on-by 099, 109, 136; Depended-on-by 172; Amended-by 175 | | 099 | The SDK accepts any Standard Schema-conformant validator and names none in its published contract — naming one makes every creator inherit it and makes replacement an SDK major; Valibot is the documented default and explicitly not a requirement; the SDK bundles no validator; creator-side validation is advisory in ADR-098’s existing sense and is re-validated server-side, which is what makes accepting any library safe; keeping Zod was rejected because the defect is the naming rather than the name, mandating Valibot because it re-incurs the same defect, and an SDK-owned adapter layer because it rebuilds a seam every candidate already implements | Accepted | Depends-on 098 (server-side re-validation at ingestion, which makes a creator’s validator a non-trust input); interacts with 010, 011 | | 100 | Descent.Sandbox loses the published defaults for the seven resource ceilings an attacker calibrates against and refuses to start until the host supplies them; the configuration delegate is mandatory so omission is a compile error, and Validate() names every gap at registration. Four options keep defaults on argued grounds — AllowUnsafeCartridgeTypes necessarily, because there the default is the control. Secure-by-default is preserved rather than traded: a library that refuses to run under limits nobody chose is secure by default in the strongest sense. A §3.1 exception for standalone-usable libraries was rejected because the consumer that failed to override the defaults was the library’s own suite, 63 times | Accepted | governs Q-017; interacts with 001, 046; Depended-on-by 137 (the ceiling discipline carried onto the WebAssembly engine, where ten figures have no default); Depended-on-by 140 (MaxTableElements already bounds the tables the reference-types proposal re-admits) | | 101 | The ARIA announcement stream is a disclosure channel in its own right — rate, ordering and interrupting silence carry what a containment property over the mirror cannot see, the same separation §14.7 already makes between revealed-set containment and ADR-034’s correction-timing rule. Scheduling is a function of tick arrival, not of what changed; a tick with nothing to announce still advances the schedule, so silence is uninformative; batch ordering is a stable function of entity identity; assertive pre-emption is reserved for event classes the player already knows about, because pre-emption is a one-bit signal before any word is spoken | Accepted | Amends 027; interacts with 034, 053 | | 102 | maskTick is a gate, not a value, so ADR-053’s attribution rule — which settles ownership of values appearing in both DOM and world space — never reached it. The Network Worker holds the gate and publishes an entity on neither channel until the older of transformTick and maskTick covers it, rather than each consumer gating separately, which would be two implementations of one security property with the accessibility one written last against a rule phrased in drawing language. maskTick is not published to consumers, because a mask-arrival signal is ADR-101’s channel by another route | Accepted | Amends 053; Depends-on 092 (the mask travels as its own message, which is what separates the two ticks on the wire) | | 103 | Semantic parity gains a non-visual expression per effect, not a fourth profile — profiles resolve from hardware capability and a screen reader is not one, so an effect can satisfy ADR-061 completely across three profiles and reach nobody using assistive technology. “None” is not an admissible declaration, on the principle by which ADR-068 refuses a descriptor with no viable C-Companion rendering; the declaration is authored with the effect, because retrofitting it centrally puts a writer who has never seen the effect in charge of what it means | Accepted | Amends 061; interacts with 027, 068 | | 104 | Room membership is a set of revocable grants, recorded as domain events on the room’s own stream — so removal is reversible by construction rather than by policy, and a rebuild replays it without re-adjudicating. Revocation propagates synchronously to exactly the two paths ADR-085 named: the ADR-026 document-write token, and ADR-022’s export entitlement re-checked at job start. A removed participant keeps their authored content, loses read access at the room boundary, loses export entitlement. Who may revoke whom is configuration with a fail-safe default a deployment may narrow and never widen. An external PDP (AuthZEN, Final Jan 2026) is rejected for the room path on three independent grounds — a network hop inside the tick budget, a second source of truth about visibility, ADR-002’s rejection of microservices — while the ReBAC model is adopted and evaluated in-process | Accepted | Amends 085; Depends-on 026 (the document-write capability token revocation must reach); Depends-on 022 (export entitlement, the other path a stale authorisation is not obvious on); interacts with 069, 078, 080; Depended-on-by 123 | | 105 | Every per-account bound is two bounds with different jobs — an abuse bound protecting other tenants, derived from the shape of legitimate behaviour and never scaled by entitlement tier, and a cost bound protecting the bill, which is where the tier belongs. ADR-087’s objection that one number cannot bound legitimate volume and abuse together was correct and one step short: it is equally a reason to have two. Q-049 is decided at 2 concurrent checkouts per account and Q-034 at 1 concurrent export — both decidable now because they describe what a human driving a UI can coherently do. Q-034’s daily allowance stays pending and its absence no longer leaves anything unprotected, since bounded concurrency caps the burn rate regardless | Accepted | Amends 087; interacts with 015, 047, 059; Depended-on-by 119 | | 106 | Cite where the number is a claim; exempt where it is working. A Normative, ⊙ or Invariant quantity must cite a Q-ID; an intermediate in a displayed derivation is exempt provided the derivation’s result cites one — because the working is not a claim, and the rule as written forbade a derivation from showing it. The exemption is declared, not inferred, since one a tool guesses at is one that grows. bare_number_lint becomes a gate once the body complies; until then it stays measurement-only with the count published and forbidden to rise | Accepted | Amends 045 | | 107 | A change to a vendored MIT module is offered upstream or declared a fork — with a named owner and a recorded reason, in the root README.md’s vendoring table, within one release. There is no third state, and “we will get to it” is the third state. ADR-100’s sandbox changes are declared a fork now; an upstream maintainer declining a breaking change justified by this platform’s §3.1 is a legitimate outcome, not a failure. Enforced as a release-checklist item rather than a CI check, because comparing a vendored tree against an upstream revision needs network access this path does not have | Superseded-by 111 | Superseded-by 111; interacts with 100 | | 108 | Descent VTT is exclusively platform-managed; the Self-Hosted Single Container profile is removed (executive decision). Two profiles remain, both platform-operated: Managed SaaS and the DR Tier. The value removed is the caveats, not the container — a deployment the platform does not control cannot carry a platform guarantee, so ADR-081’s media invariant, §6.1’s content protection and half of ADR-083’s rationale each carried an exemption for it. Those exemptions are deleted rather than reworded, because there is no longer a second deployment to exclude. ADR-081’s unchanged limit is that it never covered participants’ own local capture, and this must not be read as widening it. Accepted cost: the DR Tier alone now exercises the no-backplane path, and it runs only during an incident | Accepted | Amends 081, 083; interacts with 030, 061 | | 109 | Descent Studio boots into a small Launchpad; workspaces are installed on demand and updated independently — World Builder, Cinematic Replay Editor and Asset Manager, with asset baking and Commercial Bundle packaging in the Asset Manager so unbounded compute and signing material sit outside the window holding unsaved work. A module is a signed web bundle loaded by the WebView, a self-contained Native AOT executable run as a child process over a versioned IPC seam, or rarely a C-ABI shared library via NativeLibrary.Load — and is NEVER a managed assembly loaded at runtime, because Native AOT has no JIT and no IL loader. A managed plugin DLL was rejected as impossible rather than undesirable; in-process loading was rejected for the Asset Manager because a shared address space makes an out-of-memory bake take the editor with it. Enforced by the Native AOT publish gate, a Launchpad contract-range check and signature verification; window is Q-083 | Accepted | Depends-on 098 (the ingestion gate remains the only trust boundary); interacts with 017, 071; Amended-by 148 (a fourth workspace, the Ruleset Forge); Depended-on-by 171; Amended-by 173 | | 110 | The frontend client installs with pnpm and with nothing else, pinned by the packageManager field and refused-by-default for any other installer. Adopted for strict resolution: a phantom dependency becomes a missing-module error rather than a review comment (tech-stack-currency.md §5 q2). It found one on the first build — @eslint/js, imported by eslint.config.mjs and never declared. CI speed was offered as a second driver and the measurement refuses it: install is ~2% of the job, against 119s spent compiling wasm-pack | Accepted | Depended-on-by 139 (the property whose unverifiability under a different installer decides that ruling); Depended-on-by 174 | | 111 | The three core engines are proprietary: Descent.Geometry, Descent.RngKit and Descent.Sandbox cease MIT distribution and move from modules/ to core/ (executive decision). The engines carry the §3.1 DoS ceilings, the fairness core and the visibility oracle; publishing their source hands an attacker the exact budgets to walk under and an offline copy of what ADR-031’s divergence digests will conclude. Closed source is a business-risk control layered over the technical ones, never a replacement — §3.1’s fail-closed limits, ADR-098’s server-side re-validation and the parity corpus stand unchanged, and a control justified by “the source is closed anyway” is a defect under clause 5. Third-party integration is confined to Descent Studio plugins over ADR-109’s contract; first-party cartridges keep reaching RngKit through the unchanged Abstractions seam. Revocation is prospective as a property of MIT itself: copies already conveyed remain MIT irrevocably, so what is protected is everything unpublished since the vendoring points, which move into the record as provenance. ADR-107 retires with its premise — the alternative it rejected, owning the copies outright, is now the decision — and its diagnosis survives: there is simply nothing left to declare against. Archiving the upstream repositories is a named release-checklist obligation on the organisation, recorded because nothing in this tree can enforce it | Accepted | Supersedes 107; interacts with 017, 100, 109, 113, 114; Depended-on-by 115; Amended-by 174; Depended-on-by 175 | | 112 | Descent Docs is built on Astro + Starlight, with Pagefind search — static generation only, zero JS by default, for the platform at apps/descent-docs that will carry the Q-ID Registry Resolver and the schema scrubbing pipeline ADR-111 makes necessary. The choice is a choice of plugin substrate: both planned capabilities are unified/remark transforms. The corpus in docs/ stays the single authoritative source (P6) — the site renders it and never forks it; until the ingestion step lands the scaffold shows template content and nothing it serves is authoritative (the ingestion step landed with ADR-113, 2026-08-08). Exit cost recorded at adoption (P8): high and asymmetric — the content leaves free, the unified plugins leave as libraries, and the routing/theming/build pipeline does not; the scrubber being inside the site build couples a security control to the framework, mitigated by the standing rule that custom logic lives in framework-agnostic unified plugins with astro.config.mjs as wiring only. Pagefind arrives as Starlight’s own dependency and is deliberately not declared twice. Starlight is 0.x, pinned exact, changelog per bump — the Kobalte treatment. ADR-110’s installer discipline extends to the new app unchanged | Accepted | interacts with 110, 111, 114; Depended-on-by 113, Depended-on-by 178 (its plugin-portability and exit-cost rule is what makes the cross-document gate a standalone CLI; that gate is invoked from docs-lint.yml rather than from this platform’s build, and the reach argument for it is recorded rather than assumed) | | 113 | Corpus publication is an allowlisted, in-place, audience-conditional rendering — the docs platform renders authorised corpus files directly from docs/ via a file-enumerated manifest with no globs: a file absent from the manifest does not exist to the build, and a new file in docs/ defaults to unpublished. No corpus file is copied, moved, or given frontmatter to serve the renderer; authorization is file-granular, with no prose-region scrubbing claimed or built. The authorised set is generated by the authority-tier rule (specification, registry, blueprint tier, decision records render; design intent, proposals, working prompts, audit history, superseded material and working-state files do not, because a flat search index erases the authority ranking). Rendering is authorised while reachability is not: a deployment reachable beyond repository collaborators requires a further ruling dispositioning the registry’s operational thresholds against §3.1 before the deploy exists. Enforced by a two-sided route assertion over the built site and a build-leaves-docs/-clean witness. Derivation: Corpus_Publication_Disclosure_Review.md, findings CP-F-01CP-F-06 | Accepted | Depends-on 112 (the unified-plugin wiring rule and the assert-on-built-output check discipline); interacts with 111; Depended-on-by 114 | | 114 | The docs site deploys behind a repository-membership access boundary; thresholds stay unpublished because the audience does not widen — the ratified topology is Cloudflare Pages behind Cloudflare Access with GitHub as the identity provider, so the audience equals repository collaborators by one predicate rather than a copied list. ADR-113 clause 6(a) is dispositioned for this audience only (deployment widens nothing) and stays in force verbatim for any wider one; the public path is a named gate chain — full Q-ID citation discipline as the value chokepoint, a per-row disclosure class in the registry, fail-closed redaction with a dist-side belt, and the content findings CF-01…CF-04 closed — specified, not authorised. The deploy is CI-owned with a post-deploy smoke assertion that an unauthenticated request never returns content; site enters astro.config.mjs in the same reviewed diff as this record. The Marketplace §1.6 DRM overclaim found by the pass is dispositioned as M-F-03 independently of deployment | Accepted | Depends-on 113 (clause 6 is the condition this record discharges and re-arms); interacts with 111, 112, 115; Amended-by 178 (clause 5’s smoke assertion widens from one corpus page to seven URLs, bringing the Pagefind index inside the boundary it asserts) | | 115 | The C# scrubber is extractive, scoped to the SDK, and publishes signatures without their prose — the pipeline emits only what it positively recognises as a public interface declaration or member signature, so anything unrecognised falls to silence rather than to disclosure; the limitation is the safety property, and a fuller parser would be a worse boundary. Scope is modules/Descent.Vtt.Sdk/ alone: never core/ (ADR-111) and never the silo’s grain interfaces, whose prose was found to carry Q-ID values, a live exploit description and an admission of a protection that does not exist yet. Every comment is stripped, including /// XML documentation, because a doc comment is prose written without publication in mind — the SDK’s own docs cite Q-060/Q-061 and describe how a cartridge would bypass premise validation. Member-level suppression reuses the one existing internal-marker grammar rather than an inverted second convention; attributes are allowlisted to [Alias] so serialization ordinals do not travel. Authored creator documentation does not exist and its absence is stated rather than covered by the generated page | Accepted | Depends-on 111 (the core/ boundary that defines what must never be published); interacts with 112, 113, 114, 098 | | 116 | descent.events delivery is filtered by §8.2’s Visibility Channels, exactly as the per-frame snapshot is — the event bus is a second subscription surface and ADR-086 bound only the first, so a plugin admitted by a player could otherwise receive an event about an entity that principal cannot see. Three rules: an event is delivered only if every entity it names is in the admitting principal’s disclosure set at the tick the event is raised; an event carrying an undisclosed entity is dropped, never redacted, because a redacted event still discloses that something happened; and absence is uninformative — no “you missed an event” signal exists, on ADR-101’s reasoning that a schedule which advances regardless is what makes silence carry nothing. descent.dice results obey ADR-094: a plugin sees the net outcome its principal sees, and never an opposition’s rating, threshold or unresolved roll | Accepted | Amends 010; Depends-on 086 (a plugin’s interest set is bounded by its admitting principal’s disclosure set — this extends that bound to a surface it did not reach); Depends-on 094 (opposed-roll disclosure is a GM-delegated room-scoped policy, and the bus must not widen it); interacts with 034, 040, 101 | | 117 | The Edge Fetch Service survives with every control intact and is restricted to GMs — the “any player” population, not the mechanism, was the exposure. ADR-039’s isolation, resolved-address admission, redirect re-checks, byte/rate ceilings, quarantined decode and never-verbatim response are retained unchanged; invoking it becomes a §9.2 keeper authority. Full removal was rejected because it consolidates onto §6.2’s upload path, whose EDoS chokepoint is weaker (quota sits at presigned-URL issuance, before any Grain), and because on C-Companion “download it locally and upload it” is a hostile flow. P4 survives the change: it classifies by what enters, so uploaded bytes remain P4 whatever happens to the fetcher. Bandwidth was not the argument — §10.2 records asset egress at $0 | Accepted | Supersedes 039; Amends 010, 015 (inherited); interacts with 006, 015 | | 122 | The AI covenant is narrow, checkable, and separates three claims a broad version would merge. (1) Users retain copyright; the platform takes only the hosting-and-processing licence its own pipeline requires — §6.2 transcodes and bakes, §7.2 embeds, and a covenant implying no licence would put both outside their own terms. (2) No model is trained on user content, which is structurally true because no training pipeline exists, and is asserted by an architecture test rather than by a promise. (3) User content IS embedded and sent to the configured provider for retrieval, disclosed up front — embedding is not training, the distinction is real, and it is invisible to a reader who meets only a marketing line, which is why Studio’s publish flow carries the sentence. A guarantee the platform cannot enforce is not made: third-party providers are governed by a release-checklist item verifying a binding zero-retention/no-training term, LocalOllamaProvider is the structural opt-out, and Studio’s BYOK path is outside the covenant entirely and says so. Campaign-derived embeddings gain the erasure path they never had — deleting a campaign purges them via §7.2’s existing revocation job under the Campaign scope key, bounded by Q-054 | Accepted | Depends-on 042 (the Campaign scope key that makes embeddings purgeable as a unit); interacts with 060, 078, 098, 109; Depended-on-by 170; Depended-on-by 172 | | 123 | Custom-asset access has an authority, and the play client has no publish path. ADR-006 specified the presigned pipeline for writes and left reads to inference — the more consequential half for a private-by-default vault. Reads are now short-lived presigned URLs (Q-089) over private, unindexed buckets, minted per request with authority re-derived from the RoomGrain: a revoked grant stops issuance immediately, and a Dormant or Deactivated room mints nothing. A presigned URL is a bearer capability — response body only, never a redirect Location, never logged. This is a third path revocation must reach, alongside ADR-085’s document-write token and export entitlement, and it had the same shape as those two without being on the list. Separately and structurally: the VTT play client contains zero publishing pathways — the Vault is for play, and the Marketplace publish flow with its ToS consent event lives exclusively in Studio (ADR-109), which is where the signing material already is. Stated as absence rather than as a confirmation dialog, because a path that does not exist needs no ceremony to guard | Accepted | Amends 006 (adds the download half it did not specify), Amends 085 (adds the third stale-authorisation path); Depends-on 072 (the liveness states that gate issuance); Depends-on 104 (the revocable grant issuance re-checks per request); interacts with 109, 060 | | 119 | The per-account storage quota gets a value and a definition: it bounds stored bytes in R2, not uploaded bytes, because the two diverge under re-upload and versioning and it is the stored copy that is billed. §6.2’s presigned-URL gate already named this check and no quantity existed behind itQ-085 closes that. It is a cost bound and never an abuse bound (ADR-105), so it scales with tier and defends the bill rather than the tenant. Stored bytes and R2 Class B operations are separate quantities: a storage quota bounds what is held and says nothing about read volume, and the absence of a Class B bound is stated rather than implied covered. A deleted custom asset degrades a §7.3 replay to labelled placeholder geometry — the same destination a revoked licence reaches, reusing that state rather than introducing a fifth behaviour, with different label text because a player must distinguish an action they took from one taken about them | Accepted | Depends-on 060 (the labelled-placeholder path a deleted asset degrades to); Depends-on 105 (the cost-bound classification that permits tier scaling); interacts with 006, 022, 087; Depended-on-by 120, 121 | | 120 | The Marketplace exemption is a SHARED-COPY exemption, not a provenance one. An asset is exempt from Q-085 only where the purchaser is not the publisher, and above Q-088 distinct owners. The justification is marginal cost and nothing else: a pack owned by thousands costs the platform one copy, a custom upload costs one copy per owner — so the exemption prices reality rather than subsidising it. Provenance-only exemption was rejected as a self-service bypass: publish custom assets at exactly $0.00 (ADR-M-044 permits it), acquire your own listing (free downloads need only an authenticated account), and the same bytes at the same platform cost change quota treatment for nothing. That is not a new attack but a new payoff attached to the self-purchase laundering §8.1 already calls the core creator-marketplace risk, mitigated and not eliminated. The one-owner case is exactly where the marginal-cost argument collapses, which is why the rule tracks sharing rather than origin | Accepted | Depends-on 097 (the entitlement surface through which ownership is known, and whose predicates this must not be merged into); Depends-on 119 (the quota this exempts from); interacts with 087 | | 121 | Tiered storage unlocks are Conditional on a recurring-billing surface existing. Q-086 and Q-087 raise Q-085 for paid tiers, and the ADR is in force only once subscriptions existADR-M-027 defers them from Marketplace v1 in as many words (“four state machines”), and the Marketplace is merchant of record, so a VTT storage subscription cannot be billed today. Drafting it now records the decision without asserting machinery that is not built. Storage is a second cost driver in a price point whose existing justification is AI inference and time travel, and mixing two drivers into one price is noted as a consequence rather than hidden | Conditional | Depends-on 119 (the quota these raise); interacts with 087, 105 | | 124 | The capability veto is compiled out by construction, not by a flag and not by a mock. ADR-064 decided the veto exists and is absent from production; the shape of its injection point was the last item recorded as genuinely blocked on code that did not exist, and the frontend skeleton discharges it. The injection point is a frozen record resolved once at startup, before the first component runs, and the veto is a constructor argument — no installer, no module-level mutable state, no import.meta.env read inside it. The distinction is the security argument rather than taste: a surviving installer mutates the record the running application is using, whereas a surviving parameter can at worst build a second record nothing consults. The veto’s session half is reached only through a dynamic import() inside a static import.meta.env.DEV block, which is the shape the E2E harness already paid for — a static flag is not a tree-shake, and elimination is a property of the call site. A mocking library is rejected (it proves nothing about production, where the question is whether an override mechanism exists at all, and it cannot serve a manual QA session) and runtime feature flags are rejected (every path the veto reaches is a downgrade, so the switch would be an attacker’s supported route to each). Enforcement is three-part and each part fails differently: raw capability globals are an ESLint error outside the injection point from day one; an exact module-graph gate fails the build; a scan over the shipped output runs afterwards, because a plugin cannot observe its own removal. Guardrail 7 becomes a declaration the build reads, where a blank cell is a blank and never a skipped test, pinned by a ledger that fails in both directions | Accepted | Depends-on 064 (the compiled-out veto and executable-matrix seam whose injection-point shape this settles); interacts with 027, 052, 063, 123 | | 118 | OPFS durability is requested, not policed: the client calls navigator.storage.persist() and pre-flights StorageManager.estimate(), because the involuntary eviction path — browser reclamation under storage pressure — is the one nothing addressed. A UI warning telling players not to clear browser data is rejected: ADR-054 records clearing site data as the only escape from a stale-Service-Worker lockout, so discouraging it makes that lockout stickier. Re-download throttling is rejected on three independent grounds: §10.2 puts asset egress at $0 so it defends a cost line that does not exist; the abuse signal is indistinguishable from a GM prepping across devices, which is the trap ADR-087/105 already ruled on; and per-account per-bundle download counts would be new retained behavioural data owing an ADR-079 answer and an ADR-078 erasure path | Accepted | interacts with 054, 060, 079, 087, 105 | | 125 | Server state is cached in the UI and fetched in the network layer. §9.3 says where TanStack Query belongs — the low-frequency HTTP surfaces — and ADR-053 says the main thread never touches the network; the Personal Vault’s listing was the first code that had to satisfy both, and a queryFn beside its component fails the build. The resolution is inversion of control, not an exemption: the cache stays with the component tree, and the queryFn calls a named function exported from src/network/ which owns the request and returns plain objects. A component names a function, never an endpoint, a transport or a wire type. Exempting queryFn from the boundary check was rejected — a mechanised boundary with a named exemption is a reviewed one, two rungs down P3, and every future “just this once” fits the pattern the exemption would have to be spelled as. Putting TanStack inside the Network Worker was rejected as the tidiest-sounding wrong answer: its value is reactive integration with the component tree, which is the one part that does not survive a MessagePort. This is a MODULE boundary, not a THREAD one, and says so — whether low-frequency HTTP is additionally routed through the Network Worker is left open, with the argument on both sides recorded, because putting catalogue traffic in the single arrival authority’s mailbox is a change to what ADR-053 means rather than a consequence of this. Caching an issued presigned URL would defeat ADR-123 clause 2 — a cached bearer capability outlives the membership it was minted against | Accepted | Narrows §9.3; Depends-on 053 (the main-thread network prohibition this preserves and whose enforcement point it reuses unchanged); interacts with 029, 099, 123 |

| 126 | A transform commit is its own message, because movement is adjudicated by nothing. The client committed a drag as SubmitIntent with action: "move"; no cartridge declares that action, so every commit fell through the ruleset’s action switch and was refused as unhandled — after the lease was granted and the ghost drawn, with a comment beside the call asserting the rules engine adjudicated it. Movement authors no core event and does not bump the ADR-046 premise revision, so it travels on transform.fbs through RoomHub.SubmitTransformIntentIngress.SubmitTransformAsync, under the same four ordered checks every client-to-server path uses. One message per gesture, never one per tick — the in-drag position is ADR-050 clause 7’s non-authoritative advisory. Implementing move in the cartridges was rejected: it makes movement ruleset-specific, so a room whose cartridge cannot load could not move a token, against ADR-020’s Archive Mode. Fields on lease.fbs were rejected — a Release carrying a position lets a client move an entity by releasing a lease it never held. Re-checking the lease at commit was rejected: an expired-mid-drag commit is a late arrival, not an attack, and ownership does not expire | Accepted | Depends-on 050 (the lease this commit terminates), Depends-on 092 (the cadence rule this applies to a fourth payload); interacts with 046, 069; Depended-on-by 135 (the movement path that keeps a read-only room playable) | | 127 | Elevation and facing are authoritative transform state, and they ride the tick payload. WorldPosition gains RawZ, Actor gains a Facing, and both persist through Q-043’s transform stream so a reactivated room restores them. Facing is a fixed-point fraction of one turn (2^16 is a revolution), normalised by its type — ADR-017 forbids float on a path both hosts compute, and radians need a constant no fixed-point width represents exactly. Neither is a spatial index: WorldPosition.Cell stays two ordinates, a balcony is disclosed like the floor beneath it, and ADR-034’s observer is omnidirectional. Decoupling them onto their own message was rejected — ADR-092’s test is cadence, and a facing changes exactly when a position does; splitting them means two packets per move arriving on different ticks. Wire-and-renderer-only was rejected: a facing that never reaches the stream is correct all session and gone after the first idle collection. DisclosedEntity goes 32 → 40 bytes, so Q-056’s derivation is 68 + 40n and it admits 817 entities rather than 1,021; the cap itself does not move, and ADR-092’s 128× ratio becomes ~103×, weakening the arithmetic and not the conclusion | Accepted | Narrows 092; Depends-on 017 (the determinism contract that forbids float on a facing); Depended-on-by 129 (the affordances that produce and draw these two ordinates); interacts with 023, 034 | | 128 | Game-master command authority is a registered implementation, not a clause in the fail-closed default. OwnedActorsIntentAuthority refuses a game-master bypass in as many words, because “a game master who can command a player’s character without that having been decided is a defect nobody notices until it is used” — an objection to an undecided bypass, not to the bypass. So GameMasterIntentAuthority is a second implementation, registered in Program.cs, and the fail-closed type stays in the tree unweakened. Adding a clause to the default was rejected: the bypass would arrive as a detail inside a predicate, beneath the sentence explaining why it should not exist. Opt-in-by-configuration was rejected — a default nobody enables is a product where the GM cannot move an NPC. Ordering comparisons and negative role tests were rejected, for the reason ForceReleaseLeaseAsync already records: >= admits any role added later and != admits Unknown. This widens authority and never disclosure — §8.2’s per-viewer filter is untouched, and a GM who may move a token they cannot see is the coherent state this produces | Accepted | Resolves an open question left by 085; Depends-on 042 (the typed scope surfaces this authority does not touch); interacts with 050 | | 129 | The transform gestures are pointer modes over one lease, and a facing eases along the shortest arc. ADR-127 decided what elevation and facing are; nothing produced or drew them, and the gap was invisible because the sole call site passed literal zeroes — both fields were structurally unreachable, so a test asserting a transform was sent passed against them. Three drag modes (move / rotate / elevate) share the one ADR-050 lease, are resolved at pointer-down and fixed for the gesture; the tool rail arms one and a held Shift or Alt outranks it. A turn faces the pointer as a stateless function of the cursor’s ground position; an elevation is measured in screen space and snapped to whole cells by client convention, not by any server ruleWorldPosition.Cell is two ordinates and RawZ has no quantisation. A facing eases in raw turn units with the arc reduced into [−2^15, 2^15] and the exact half-turn tie broken deterministically, because every viewer computes it independently. Every commit carries all four ordinates, so “unchanged” is a value read and sent — the client half of the rule that removed Room.MoveActor’s default facing. A Babylon RotationGizmo was rejected: it brings a second pointer-capture system over one canvas, draws three axes for the one-axis quantity ADR-127 chose deliberately, and needs a selection concept this client does not have. A world-space elevation handle was rejected as degenerate at shallow camera angles; an elevation slider for putting a spatial affordance outside the space; radian interpolation for putting the ADR-017 boundary inside a render loop; a modifier-only affordance for being invisible. A token gained a facing prow and an elevated token a drop line, without which both values are correct, replicated and unobservable | Accepted | Depends-on 127 (the authoritative ordinates this produces and draws); Depends-on 050 (the one lease all three gestures take and release); Depends-on 017 (the determinism contract that keeps the ease in turn units); interacts with 023, 053, 064 | | 130 | The VTT’s half of the Ticket Exchange is built, and the VTT never receives or parses the Marketplace identity JWT (§2.1, §4.8). The redemption result carries a subject and an entitlement hint and cannot express a role — a role the server did not derive defeats the disclosure filter exactly as a self-asserted JoinRoom parameter would — so authentication establishes who is calling and widens no viewer’s disclosure set, the membership model being an open product decision (085). Redemption is skipped on the SignalR negotiate request: a connection is two independently authenticated HTTP requests and the ticket is a row consumed by one atomic update, so redeeming on both would fail every production connection while every test against an idempotent dev redeemer passed. The client acquires a ticket once per connection ATTEMPT rather than per connect(), because a 15-second single-use credential cannot survive ADR-054’s forty-attempt ladder or any later reconnect — the shape that fails is the one where the first connection works and every recovery does not. A refused ticket does not abort the connection: a WebSocket upgrade’s HTTP status is invisible to page JavaScript, so a drop would be retried as an unclassifiable transport error; the connection is left inert and refused at the first hub method, which the client already treats as terminal. skipNegotiation was rejected for destroying that classification, a redemption cache for widening replay, and local JWKS verification for putting the issuer’s keys inside this context’s blast radius. The ViewerId stays per-connection rather than derived from the subject, because a derived one makes the identity linkage the hash function and is not severable by deleting a record (078). Identity mode ships no default on the silo and falls back on the client, which is safe only as a pair | Accepted | Amends 097; Depends-on 029 (the WebSockets-only transport that makes one redemption per connection true); Depends-on 054 (the Edge-Hold ladder the per-attempt acquisition and the two new phases answer to); Depends-on 078 (the erasure-by-construction property that forbids a subject-derived viewer id); interacts with 085, 050, 123; introduces Q-093, Q-094; Amended-by 131 (its two recorded gaps — the durable subject-to-player mapping and the unconsumed entitlementHint — are closed) | | 131 | The durable identity ADR-078 requires is a NEW type, PlayerId, and ViewerId stays per-connection. ViewerId is the transport’s routing identity — ViewerConnections maps it to at most one connection and RoomSnapshotDispatcher keys its per-room registry on it — so making it durable makes two tabs of one person share it: the second displaces the first, and because deregistration removes the single entry, whichever tab closes first silences the survivor. A durable-identity change presenting as a renderer bug. The subject-to-player mapping is stored and never computed, because any pure derivation makes the linkage the algorithm — recomputable by anyone holding the subject, so deleting the row would sever nothing and leave retained history re-identifiable by the very party the erasure was performed against. “Outside the event store” is claimed in the sense that carries the guarantee — deletable, not an event, not in a stream, own schema, not the Marten session — and explicitly not as a second database. Entitlement is one EXISTS against vtt_entitlement_v1 on the replica through a typed surface that cannot be called without an EntitlementContext; a replica miss is authoritative unless §2.5’s grant hint is present, in which case exactly one primary read follows and increments vtt_entitlement_primary_fallback_total — no loop, no retry, no cache. Finding M-F-02: the hint’s value cannot be checked against the contract, because the view projects (account_id, resource_urn, granted_at) and does not expose entitlements.id, so only its presence is load-bearing and the sharper “skip the primary read when the hinted grant is already visible” is unwritable against _v1. Marketplace mode will not start without durable linkage storage | Accepted | Amends 130; Depends-on 078 (erasure by construction, the reason the mapping is stored rather than computed); Depends-on 097 (the Global-scoped entitlement surface and its five clauses); Depends-on 050 (the lease identity a shared viewer id would collide on); interacts with 042, 082, 085; discharges ADR-097 clause 3; Depended-on-by 132 (the opaque PlayerId a room’s owner is recorded as, and the linkage whose reverse direction Tier A required) | | 132 | The dual-tier entitlement model, and the membership model ADR-085 left open. Owner, game master and viewer are three answers to three questions — may this principal pay for it, may they command it, may they see it — and the owner is admitted as a Player with no mechanical authority, because a group that buys a system and hands the screen to tonight’s referee is the common case. Exactly one game master, demoted in the apply so replay reproduces it, since ADR-094 needs one policy-setting authority. The cartridge is bound at creation with no method that changes it, and RoomCreated’s two new members are optional rather than required, because required ones fail to deserialise every pre-ADR-132 stream including ADR-065’s replay corpus. Tier A’s check is DISPATCHED rather than awaited in OnActivateAsync: awaiting a round trip in activation blocks every activation when the entitlement store is slow, Orleans’ activation timeout then fails it, and the next message retries — a hot loop against a struggling dependency, which ADR-033 forbids and which the hydration saga beside it already solved with a flag. An unprovisioned room skips the gate (no cartridge, nothing to protect); a provisioned room with no gate locks. The gate carries no grant hint and the creation filter does, because §2.5’s hatch is for a buyer purchasing in-session and an activation has no connection — so there are two checks over one rule and only the gate is authority. Content modules are feature flags pooled behind the owner’s subscription. Findings M-F-03 (the subscription is inert until ADR-M-027 gives the Marketplace a recurring-billing surface) and M-F-04 (§3 permits caching entitlement within a room’s lifetime while ADR-097 rejects a per-activation cache; resolved as derived T0-only state with the staleness stated). Erasing a subject locks the rooms they own, which is the intended behaviour | Accepted | Amends 085 (its openness clause only; the departure/erasure ruling stands); Depends-on 097 (the entitlement surface and clause 4’s separation of authorisation from disclosure); Depends-on 131 (the opaque PlayerId and the linkage this reverses direction on); Depends-on 033 (the mailbox discipline that decides how the activation check is dispatched); Depends-on 078 (the erasure property whose new consequence is recorded); interacts with 128, 094; Depended-on-by 134, 136 | | 134 | A cartridge is held through a LEASE, and the lease is the only route to an engine. RoomGrain built its ruleset dictionary once from DI — correct while every cartridge was compiled in, unable to express a release, and registering none on a real silo, so production answered UnknownRuleset for every action while only the test hosts had engines. The failure this is shaped around has no symptom: a host that decrements a reference count while still holding an IRulesetEngine gets a context that is unloaded and never collected, and the rooms keep working. So CartridgeLease.Dispose drops the engine set before running the release and a disposed lease throws rather than answering — release and drop-the-reference become one act, enforced by the type (P3). One collectible context per (urn, version) per silo as §4.4 already required; a context per room was rejected because the ALC is not a security boundary, so per-room isolation buys nothing against full host privileges while paying for every assembly twice. Acquisition is a saga stage — a first load is unbounded I/O, §4.4’s third forbidden class — and the lease is published to a field and adopted in the mailbox, never passed as a call argument, since handing a cartridge-typed object to Orleans’ serializer is a second way to pin the context. The stage runs only where the room would have become Ready, which keeps ADR-132’s locked-room-never-boots true. Unloading on the last release was rejected, and this narrows §4.4: Orleans deactivates idle rooms routinely, so zero-means-unload re-reads and re-JITs continuously, and because Unload is asynchronous it leaves several generations alive at once — the duplicate-context waste that rejecting per-room contexts avoided, by another route. A cartridge lingers unheld for Q-097. The deactivation drain it is ordered after does not exist (architecture-rules §2 clause 3 is unimplemented in OnDeactivateAsync), and §4.4’s leaked-ALC metric is still unbuilt | Accepted | Narrows §4.4; Depends-on 033 (the mailbox discipline that makes the load a dispatched stage rather than an awaited activation step); Depends-on 055 (the SDK-major admission this loader applies at load time); Depends-on 132 (the entitlement decision the load is placed after, so a locked room’s context never boots); Depended-on-by 135, 136; introduces Q-097; interacts with 020, 037; Superseded-by 162 (the ALC loader is deleted; Wasmtime is the only cartridge host) | | 135 | Archive Mode is a property of the cartridge binding, never of the room lifecycle. ADR-020 requires a room whose cartridge cannot load to open read-only, and the obvious implementation — a RoomLifecycle value — is wrong, and this corpus already paid to find out why: Guard() switches on the lifecycle, so such a value refuses movement, chat, leases and the tick, and ADR-126 rejected ruleset-specific movement in as many words precisely so that “a room whose cartridge cannot load” can still move a token. It would also have passed every test that existed, because nothing asserted the boundary. So an unloadable cartridge reaches Ready holding an archived lease: the room ticks, replicates, moves, chats and leases, and only SubmitActionAsync and AuthoriseAttributeAsync — the two paths that consult an IRulesetEngine — refuse. The refusal is ActionStatus.RulesetArchived and is answered before UnknownRuleset, because an archived room has no engines so every ruleset id looks unknown to it, and reporting that tells a game master to load a cartridge the platform has already refused. The reason is readable through the grain, since a room in Archive Mode is Ready and ticking and the lifecycle alone cannot tell an operator a cartridge failed. Per-ruleset archiving inside a mixed room is out of scope and said so: ADR-132 binds one cartridge per room, so §3.1’s per-ruleset wording and this name the same set today | Accepted | Amends 020 (its “new commands are refused” clause, scoped to cartridge-adjudicated commands); Depends-on 126 (movement being adjudicated by nothing, which is what makes a read-only room playable); Depends-on 134 (the lease whose archived state carries the reason); interacts with 055, 065, 132 | | 136 | Provenance is established over a signed MANIFEST, not over an assembly. §6.4’s P0 row requires signature verification at load and nothing verified anything, so the tier rested entirely on a review gate outside the code. A cartridge directory now carries cartridge.json, a detached signature over its exact bytes, and a SHA-256 digest for every assembly the context may load; the file set is closed, each digest is checked as that assembly loads, and the verified manifest’s urn is checked against the urn that was asked for. Signing only the entry assembly was rejected — it leaves the urn unsigned, so anyone who can write the directory points a free urn at a paid cartridge’s image and ADR-132’s Tier A gate authorises the wrong thing, an entitlement bypass reached without touching code — and it leaves every dependency unsigned, when a cartridge’s rules can live entirely in a library beside it. Authenticode was rejected as Windows-only verification for a Linux silo, strong naming as an identity that .NET Core does not verify, ML-DSA as the sole suite because MLDsa.IsSupported is conditional on the base image and the failure mode is a silo of Archive Mode rooms — so the suite is named in the manifest and a second one is a second verifier. Trust anchors are host configuration with no default, and an empty list is refused rather than read as trusting everything. ADR-098 is carried unchanged: this establishes provenance and never safety. The publishing tool does not exist — signing lives only in a test fixture | Accepted | Depends-on 098 (signing establishes provenance and never safety, carried unchanged); Depends-on 132 (the urn the manifest binds, and the Tier A check a re-pointed urn would bypass); Depends-on 134 (the load path this check gates); interacts with 020, 055; Depended-on-by 174 | | 137 | A WebAssembly engine joins Descent.Sandbox beside Jint, and Jint is NOT retired. Replacing it was the proposal and does not survive the mission: ISandboxEngine takes JavaScript source because what it runs is a macro a Keeper typed mid-session, and there is no compiler at that table — a kind-C conflict, so the property (ad-hoc, uncompiled, session-time execution) is what decides rather than the technology. IWasmSandboxEngine is a second port with its own registration, because a host running only macros must not load a native runtime and one running only compiled content must not build a Jint pool (ADR-100 clause 5’s argument, twice as strong for a native dependency). A payload may declare NO imports at all and one that does is refused before instantiation — the whole of invariant 1 in one check on an empty list, where the Jint side needs several hundred lines to reach a weaker version, because a JS realm arrives with capabilities to take away and a module arrives with none to give. Invariant 2 is structural: a Store owns the guest’s world and is created and disposed per execution, so there is no scrub, no pool and no cross-tenant reuse. What decided it was measured, not argued: unbounded guest recursion traps StackOverflow and the engine survives — where Jint’s MaxRecursionDepth is hard-capped at 50 precisely because above it the StackOverflowException is uncatchable and the silo dies — and fuel is deterministic (10n + 5 at two magnitudes). MaxWasmStackBytes is capped in its SETTER, not in Validate(), because an invalid wasmtime Config panics in the Rust c-api and aborts the process: the one control here whose failure is not an exception. Two new error kinds, FuelExhausted (kept apart from Timeout because only one is reproducible) and InvalidModule (kept apart from Syntax because a build artefact has no line). Costs, stated: a payload cannot call the host at all, no fixture is a real toolchain’s output, Studio does not exist so there is no producer, and the silo has no consumer yet. ADR-100’s Jint ceilings were NOT torn down although the proposal authorised it — they govern a reachable path that still runs untrusted JavaScript | Accepted | Depends-on 100 (the ceiling discipline carried forward: ten figures with no default); interacts with 098, 111, 134; introduces no Q-ID; Amended-by 140 (its BuildConfig proposal set only — reference types enabled so a real toolchain’s output can load; clause 3 unchanged); Depended-on-by 141 (clause 3 is why the guest cannot reach the host’s generator); Depended-on-by 144 (the client worker adopts the same three names and the same empty import list), Depended-on-by 145 (the envelope generated from the schema is the one that rides its ABI); Depended-on-by 147 (a cartridge is a core module satisfying clause 3); Depended-on-by 163 | | 138 | WebTransport is declined and ADR-029’s Phase 5 evaluation closes negative. P7 requires checking a blocking claim rather than inheriting it, and two of the three were checked: SignalR still has no WebTransport transport (dotnet/aspnetcore#39583 is open, Backlog, unassigned, no linked PRs), and Azure Container Apps ingress carries HTTP and TCP only — no HTTP/3, no QUIC, no UDP, requested since June 2022. The second inverts the proposal’s own framing: the request was to prioritise WebTransport and keep a fallback for where UDP 443 is blocked, but on the deployment target UDP is always blocked by the platform, so the fallback would be the only path that ever runs and the WebTransport path would be a control that executes nowhere. No client code is written: WebTransportTransport implements Transport remains one file, but writing it now produces a transport with no server, and this repository has recorded that exact shape three times — a declared type is not a wire, a reachable endpoint is not a working feature, a field nothing writes is a field that does not exist. The seam was audited instead and holds. Kestrel HTTP/3 is not enabled and the configuration that would enable it is documented with the four things that must also be true, so that its uselessness on this target is visible in the same place — the honest answer to “document the ACA HTTP/3 configuration” is that there is none that works. Re-evaluation needs both clauses: the issue shipping, and ACA gaining UDP ingress | Accepted | Amends 029 (its Phase 5 clause only; the WebSockets baseline is unchanged); interacts with 026, 054 | | 139 | Bun is declined for both frontend pipelines, and the BFF’s runtime is left with its owner. Three separable things, evaluated separately. (a) Installer: Bun preserves ADR-110’s phantom-dependency property only under --linker=isolated, which is not the default for a single-package project, and both frontends are single-package roots. No environment variable reports the effective linker, so a preinstall guard could assert the brand but not the property — converting a structural guarantee into an unverifiable one, which is upgrade-and-supersession.md §7 verbatim. The upside is bounded by ADR-110’s own measurement: install is ~2% of the job. (b) Bundler/test runner: replacing Vite is not available, because SolidStart 2.0 is a Vite plugin; replacing Vitest costs a second configuration against a seconds-long suite. (c) Runtime: the stated driver was cold start, and two figures the corpus already owns refute it — §2.6 pins the BFF at min-replicas 1 with scale-to-zero explicitly refused, so there is no scale-from-zero cold start; and where one exists, Q-M-013 is measured at 8 969 ms and §3.1’s M-F-02 decomposes process start as 100–200 ms of it, an argument made for Native AOT that transfers to Node → Bun unchanged. The Node 22-versus-24 question keeps its owner (tech-stack-currency.md §3c, a deployment decision) and gains Bun as a recorded unmeasured third option. Two premises of the proposal were false and are corrected: the pipelines run pnpm rather than npm, and there are no deployment Dockerfiles for either frontend. Three triggers recorded rather than “not yet” | Accepted | Depends-on 110 (the phantom-dependency property, and its measured 2% install share); interacts with 112 | | 140 | The reference-types proposal is ENABLED in Descent.Sandbox’s WebAssembly engine; multi-value stays disabled. Paying ADR-137’s own recorded debt — “demonstrating that a Rust-, C- or AssemblyScript-produced module conforms is the first thing Studio’s compile step owes” — found the debt unpayable: no Rust wasm32 module could load at all, including a twelve-line std crate whose only content is format!, refused with Invalid input WebAssembly code at offset 822: zero byte expected. Rust enables the proposal for wasm32 targets and ships its precompiled std that way, so call_indirect carries a LEB table index where the MVP encoding requires a reserved zero byte; -Ctarget-feature=-reference-types does not reach std and only -Zbuild-std does, which is nightly against a deliberately pinned stable toolchain. This is a relaxation and is argued rather than committed (CLAUDE.md §6 rule 4): the alternative is not a stricter engine but an engine with no possible producer, and what the proposal adds — externref/funcref, multiple tables, table.* — has no reach in a module that imports nothing, because an externref can only carry a reference the host handed over and clause 3 guarantees the host hands over none. multi-value was checked separately, is not required, and stayed off — the pair was not assumed to travel together. The argument turns on the empty import list and does not transfer to any future engine that grants an import | Accepted | Amends 137 (its BuildConfig proposal set only; clause 3’s empty-import invariant is unchanged and is what makes the relaxation admissible); Depends-on 100 (MaxTableElements already bounds the re-admitted tables); introduces no Q-ID; Depended-on-by 159 | | 141 | descent-wasm-core is a SECOND implementation of DESCENT-DRBG-HMACSHA256-CTR-v1, admitted under the oracle exemption, and Descent.RngKit is NOT retired. A guest that cannot call the host cannot call the host’s generator, so the algorithm is reimplemented in Rust — which is the second implementation on the product path ADR-017 forbids. It is admitted on rust-geometry-guidelines.md §4’s grounds: what is forbidden is implementations that must agree with no mechanism that detects when they do not, and WasmCoreDrbgParityTests is that mechanism — comparing the two live over five die shapes, never against literals, and never to be regenerated from either side. The mutation sweep is part of the ruling, not a footnote: two of six mutations initially survived, so the exemption was briefly backed by nothing. Rejection sampling had no reachable case (d100 rejects one draw in 45 million; replaced with d1073741825, which rejects one word in four), and the entropy-separation test compared two different mappings of the same word. A third defect was exposed rather than caught — sides as i32 wrapped above i32::MAX, now refused as the one ceiling §5’s no-crate-constants rule does not reach, being representability rather than a refusal threshold. RngKit’s retirement was requested and is REFUSED: it is Descent.Vtt.Sdk’s authoring API, consumed by both shipped cartridges, and the guest replaces one of its dozen evaluators. Its precondition, recorded rather than dated: the cartridges themselves becoming WebAssembly components | Accepted | Narrows 017 (a second implementation is admitted where divergence is structurally detectable — the same exemption its oracle has); Depends-on 137 (clause 3 is why the host’s generator is unreachable); interacts with 100, 111; Depended-on-by 162 | | 142 | Native AOT is DECLINED for the silo, blocked by Orleans; ReadyToRun is available and is NOT the default. The cold-start premise was already refuted by figures this repository owns — Q-M-013 is measured at 8 969 ms and M-F-02 puts process start at 100–200 ms of it — so the probe was run for the memory and footprint claims, which those figures do not answer. Native AOT compiles, links and starts, then dies in ReferencedAssemblyProvider.GetRelevantAssemblies(): Orleans 10.2.2 bootstraps its serializer by walking DependencyContext and loading assemblies by name, and an AOT image has none to load. ILC predicted it (IL3002) before emitting anything. Three expected blockers were not blockers — Marten, SignalR and Npgsql were all reached past, and removing Jint would not have helped because Jint was never reached. ReadyToRun was measured as the requested fallback and is 1.9× the image (56.42 → 106.01 MB) for a startup delta inside the noise (278 vs 270 ms, ranges overlapping), against a deployment whose dominant term is image pull. PublishReadyToRunComposite stays off; TieredCompilation stays on, because R2R code is entered at tier-0 quality. What was not measured is stated: the run stops at DI validation, so it bounds startup and says nothing about tier-up over a live session — the tick-latency measurement that would decide R2R does not exist, and is the price of making it default. Trigger: Orleans shipping a source-generated serializer registration that does not require assembly enumeration | Accepted | Depends-on 033 (the 20 Hz tick body is what TieredCompilation is kept on for); interacts with 137; introduces no Q-ID | | 143 | The universal WebAssembly geometry core is DECLINED on Q-015a. The architectural half is sound and better than proposed: descent-geometry-wasm cannot serve (it is wasm-bindgen and imports three __wbindgen_* names), but bindings/c_abi compiles to wasm32-unknown-unknown unchanged — 74 KB, zero imports — so the C ABI is the WebAssembly ABI recompiled, one source and three hosts. The performance half refutes it. Identical request bytes through both hosts, agreement asserted before any timing: 3.85× slower where the call boundary dominates and 5.32× where the arithmetic does. Applying the latter to §6’s measured 29.4 ms fog advance — a derived figure, and flagged as one — takes Q-015a’s 300 ms/room-second ceiling from 10 advances per room-second to ~1.9, an 81% cut; §6 refuses the obvious remedy in advance, because “an isolation ceiling raised to fit its load stops being one.” Both ends of the measured range break the budget, which is what makes it decidable without the fog measurement. descent-geometry-c-abi stays, no P/Invoke was removed, ci.yml and Descent.Geometry.Native.targets are unchanged, and brief tasks 9–11 were not performed. Trigger: a wasm-vs-native measurement of the fog path itself — §6 records the fog estimate once being wrong by 278×, so transferring an LOS ratio to mask advancement is an assumption, not a result | Accepted | Depends-on 017 (the single-implementation rule this would have strengthened and the determinism contract it must not weaken); constrained by Q-015a; interacts with 033, 052; Amended-by 177 |

| 144 | The client plugin worker speaks the SAME three-name guest ABI as the silo, and Extism is declined. ADR-040’s PluginWorker did not exist, so its ABI was a green-field choice rather than an upgrade. It is memory, descent_alloc, descent_invoke — ADR-137’s, unchanged — and a module declaring any import is refused before instantiation. On the client that refusal is a choice, not an inheritance: the browser worker legitimately needs a host edge and ADR-040’s design is built on one, so the empty import list is bought rather than inherited, and what it buys is that one wasm32-unknown-unknown artefact runs under wasmtime in the silo and WebAssembly.Instance in the browser with no second build, binding or conditional path. ADR-137’s objection to Extism — that its plugin model reaches the host through imports — therefore does not transfer; Extism is declined on the different and durable ground that it would give the platform two plugin ABIs, one per tier, at the moment the cartridge and plugin surfaces are converging on one. Re-evaluate if the platform ever wants a plugin manifest format and a registry it does not own, which is the part Extism genuinely provides. A browser-native scrubbed-Worker sandbox is three orders of magnitude faster and is rejected on containment: a worker global still has fetch, WebSocket and indexedDB, so its isolation would be a deletion list of the kind ADR-010 already refuses, and the Jint path is the worked example of what one costs. Measured 2026-08-10 on V8 and Chromium: the 3.03 MB payload compiles in under 5 ms against wasmtime’s 422 ms, so ADR-137’s cold-start objection and its precompile mitigation have no browser counterpart at all | Accepted | Amends 040 (its clause 1 QuickJS runtime becomes a wasm32 guest on the shared ABI); Depends-on 137 (the three names, the empty import list, and the artefact that satisfies them); interacts with 010, 017; introduces no Q-ID; Depended-on-by 165 | | 145 | The guest envelope is written down ONCE, as a JSON Schema, and its Rust, C# and TypeScript bindings are generated from it. The Mega-Epic Audit §6.2 found exactly one advantage in .wit components that survived scrutiny — “generated bindings on both sides, so a contract change is a compile error” — and recommended taking the discipline without the component model. The drift was worse than recorded: the shape was hand-written four times, once as Rust serde structs and three times as C# anonymous objects, with a fifth about to be added in TypeScript, and two of the four already carried different limit values with no way to tell deliberate from accidental. contracts/guest-envelope/v1.schema.json is now the contract and carries the prose as well as the shape, so the load-bearing paragraphs sit where a reader of the contract finds them. The generated files are committed, which is the opposite of the wire codegen’s rule, and the difference is the consumer count: cargo build and dotnet build must not acquire a dependency on Node to compile a struct. The generator is purpose-built and refuses any construct it does not understand, by keyword and JSON path — a general generator’s characteristic failure is to emit something plausible for a construct it half-supports, and the divergence then surfaces three languages away at run time, which is the failure the mechanism exists to remove. intents and input stay deliberately outside the schema as pre-serialised fragments, because the intent shape belongs to the ruleset author and a parse-and-re-emit round trip renormalises numbers | Accepted | Depends-on 137 (the envelope is what rides its ABI); Depended-on-by 147 (the generated envelope is what replaces .wit’s binding generation); interacts with 112; introduces no Q-ID; Depended-on-by 149; Depended-on-by 150; Depended-on-by 165 | | 146 | Spike S6 is CLOSED: ADR-040’s interrupt-driven preemption is not implementable in a browser, and what replaces it is stronger in one half and weaker in the other. A synchronous descent_invoke cannot be interrupted, and every mechanism that would allow it — an imported host callback the guest polls, SharedArrayBuffer plus Atomics.wait — requires granting the guest an import that ADR-144 refuses. The hard ceiling is therefore Worker.terminate(), measured at 2.19 ms on a guest spinning in an empty while (true) loop — a kill rather than the cooperative unwind clause 2 asked for, and uncatchable. The soft ceiling degrades to after-the-fact detection, deferring the plugin’s next invocation, which is weaker and is exactly the fallback S6 anticipated. Clause 2’s rejection of one-worker-per-plugin is reversed on measurement: a compiled WebAssembly.Module is structured-cloneable and the clone shares compiled code, so N runtimes cost N linear memories rather than N interpreters — though an isolate each is still paid and that half of the objection stands. An instance is created fresh per invocation, because a held one leaks a measured 40.5 KiB per invocation on both engines: the ABI has no descent_free by design, the silo disposing its whole Store instead. The supervisor holds the ledger, the queue and the RPC bridge and never executes creator code, which is the only arrangement in which clause 3’s “a stuck plugin MUST NOT freeze the GM’s combat tracker” is true — a priority scheme cannot deschedule what has already started — and a spinning child is measured as invisible to the supervisor’s message loop. ADR-040’s own O(n²)-over-500-tokens example is confirmed at roughly half a second, which is of the order of 1 300× the same logic in the host engine; the audit’s 24× was against Jint, and in a browser the comparison arm is a JIT. It follows that the JavaScript tier must not be on the frame path, and ADR-144’s polyglot tier is the answer for anything that is | Accepted | Amends 040 (its clause 2 preemption mechanism and its clause 1 runtime placement; closes Spike S6); interacts with 052, 053, 096; introduces no Q-ID; Amended-by 151; Amended-by 165 | | 147 | A cartridge is a core wasm32-unknown-unknown module declaring ZERO imports and exporting the three ABI names — not a WebAssembly component. componentize-dotnet is declined on the Mega-Epic Audit §6.1’s three compounding grounds: a component is not a core module and the engine sets WithComponentModel(false); components target WASI and import their world, where preview 1 emitted eight imports and preview 2’s worlds are more structured; and componentize-dotnet is built on NativeAOT-LLVM, so a cartridge SDK would inherit a reflection ban Descent.Vtt.Sdk’s AttributeBagJsonConverter fails today. Re-evaluate when there is a second ruleset author AND the SDK is AOT-clean — §6.2’s own measure is that the cost being paid is contract drift, and ADR-145 has removed most of it in the meantime. The pipeline is tools/forge/build-cartridge.mjs with two gates: a microsecond shape gate over validity, imports, exports and size, and an opt-in engine probe running real wasmtime with the silo’s real proposal set. Only the probe can answer whether the silo will accept a module, because Node’s engine enables proposals the silo disables — audit §2.4 is that exact failure — and a run without it prints what it did not check rather than implying a clean bill. The size ceiling is host configuration with no default (ADR-100). A 336-byte no_std, zero-dependency template is the scaffold, and it narrows audit finding F-2: it loads under all four proposal combinations, so F-2’s cause is Rust’s precompiled std rather than Rust. That does not reopen ADR-140, whose payload needs std for Boa; it records that an all-no_std cartridge policy is an option that exists | Accepted | Depends-on 137 (the ABI and the empty-import invariant a cartridge satisfies); Depends-on 145 (the generated envelope is what replaces .wit’s binding generation); Depended-on-by 148 (the workspace’s whole job is that pipeline); interacts with 071, 098, 111; introduces no Q-ID; Depended-on-by 149; Depended-on-by 159 | | 148 | The Ruleset Forge is a FOURTH Descent Studio workspace, beside World Builder, the Cinematic Replay Editor and the Asset Manager, installed on demand so a creator who only makes maps never downloads a Rust toolchain. The brief’s reason was memory isolation from map-making; the stronger reason is the Mega-Epic Audit §6.4’s — a cartridge compile is a cranelift-scale operation, 422 ms for a 3 MB module and paid on every build, plus a toolchain and an artefact cache, none of which shares a working set with meshes and textures. It owns ADR-147’s pipeline including the engine probe, so a creator learns at build time rather than at publish time that their toolchain flags differ from the silo’s. It must never ship its precompiled output: a serialised module is tied to the wasmtime version and the target CPU that produced it, so a CDN of such blobs is a fleet-wide invalidation on every runtime bump — a rule that binds the silo’s deployment only, since ADR-144 measured V8 compiling the same payload in under 5 ms. Its compute half is a child process, by ADR-109’s second artefact kind, on the Asset Manager’s argument applied to a different unbounded workload. Everything it reports stays advisory under ADR-098: Studio is an untrusted producer, the ingestion gate remains the only trust boundary, and the Forge’s gates move a creator’s discovery earlier rather than moving that boundary. Studio still has no code, so the workspace’s own publish gate is recorded as an open item rather than claimed as present (ADR-045 clause 2) | Accepted | Amends 109 (adds a fourth workspace to its three); Depends-on 147 (the workspace’s whole job is that build pipeline); interacts with 098, 111 | | 149 | A ruleset and an expansion book are TWO relationships and get two mechanisms; rule flags are retired. BRP → CoC 7e is kernel and policy: descent-brp owns the d100 resolution, the ladder’s reading, the difficulty mechanism and the resistance table with no system’s numbers in it, and a derived system implements System, a trait bundling associated policy types — static dispatch, and a partially-implemented system does not compile. The C# design had already found this half and its comments say why: four tiers where BRP has three, no thresholds agreeing, and hit points a tenth of CON + SIZ rather than a half, because CoC 7e characteristics are percentile — reusing BRP’s derivation gives an investigator 65 hit points instead of 13, “a number that looks unremarkable on a sheet and is catastrophic in play”. CoC 7e → Pulp Cthulhu is content layering: an expansion is a RuleLayer amending a closed set of four decision points, folded in declared order by a LayerStack that records {layer, decision, before, after} for every value that changed. Flags are refused on five properties a boolean has none of — locality (the base ruleset must not name its expansions, asserted on both the manifest and the source), composition (k flags are 2^k untested configurations; a stack has one dimension and it is data), attribution (ADR-040 clause 5’s argument in another subsystem — a player asking why they have 24 hit points gets a sentence naming the book), identity (a layer can be versioned, signed, deprecated under Q-046 and sold; a flag cannot), and third-party extensibility (nobody ships a flag into somebody else’s if). The closed set is the constraint the design rests on: an open surface is a plugin API, and nobody can say what a plugin changed. Cargo features were the closest alternative and fail on entitlement and attribution; ECS composition was rejected as a category error, since what varies is which function computes a number rather than a character’s component set; cross-module composition by host-chaining artefacts is right for a third-party expansion and is not implemented. The host declares the stack in input, in order, and an unknown layer id is refused rather than skipped — a Pulp game that silently ran without Pulp is discovered from a character dying. A ruleset cartridge draws no dice: the authority that owns them supplies them (ADR-016), which also avoids a third implementation of a frozen DRBG contract that would need a third parity gate. Measured: 157 306 bytes, zero imports, accepted by wasmtime under the configuration the silo ships — one twentieth of the JavaScript payload | Accepted | Depends-on 147 (a cartridge is a zero-import core wasm32 module); Depends-on 145 (the generated envelope is what it speaks); interacts with 016, 017, 055, 071, 134; introduces no Q-ID; Depended-on-by 162 | | 150 | The guest ABI payload stays UTF-8 JSON, on a measurement that refutes the intuition behind changing it. Measured 2026-08-11 over a real CoC 7e cartridge at three payload sizes: serialisation is 72–88% of an invocation, which reads as a bottleneck until the second figure — the whole invocation is 3.15 to 10.87 µs, or 0.065% of a 60 Hz frame at the heavy workload, and rmp-serde decodes the same struct 7–10% slower than serde_json in both directions at all three sizes. The share is large because the rules are cheap, not because the serialisation is dear; MessagePack’s real advantage is size (14–32% smaller) on a payload that never leaves the process. FlatBuffers is declined on a ground that does not involve timing at all: flatc is pinned at exactly 25.2.10 with six mirror locations, and adopting it would put that pin into every cartridge author’s build — against ADR-147’s argument that the contract is three exported symbols and a byte range rather than a framework, and a 336-byte no_std template cannot carry a FlatBuffers runtime. A hybrid binary-envelope/JSON-input was rejected as the worst of both. The probe found one avoidable cost that is not about the format: the input’s bytes are parsed twice, once by the envelope’s RawValue scan and once by the cartridge, worth 1.85 µs of the heavy workload — inherent to the nesting, which MessagePack would also pay, and recorded rather than acted on. Trigger: re-measure if a cartridge’s input exceeds roughly 100 KB per invocation, which a full world snapshot at 20 Hz would reach. The figures are host-target; the ratios transfer where the times do not | Accepted | Depends-on 145 (the schema and its generated bindings are what would have to change); interacts with 137, 147, 149; introduces no Q-ID | | 151 | The client plugin tier’s consumer exists, and building it resolved a contradiction between two accepted ADRs. Clause 4 of ADR-040 copies the snapshot into the guest every frame; ADR-146 measured the JavaScript tier at roughly 1 300× and concluded it must not be on the frame path. The resolution: a frame boundary is posted every animation frame — accounting, one postMessage, and posted even on an empty queue, because a frame that skipped it would not clear the aggregate either — while an invocation is queued when the world changes, since a deterministic guest over byte-identical input answers identically and the invocations removed are exactly the ones that could not have changed anything. A plugin has at most one invocation outstanding: without that bound the queue grows without limit the moment a plugin is slower than the world cadence, and the supervisor’s correct FIFO deferral becomes a backlog that can never drain. The interest set is a closed vocabulary, refused on the main thread before the worker sees it, on budget.ts’s own argument that a truncated interest set produces wrong answers instead of no answers; the snapshot carries the wire’s Q16.16 integers unconverted, because a helpful division would hand a plugin a float the silo never had; and it is serialised once per (interest set, world version) rather than once per plugin, which is the cost clause 4 names. The init message now carries a payload URL rather than a compiled Module, which amends ADR-146’s message and not its property — one compile cloned to N runtimes sharing compiled code is unchanged, and what moved is which thread pays, because the main thread must not fetch (§9.6.1) and a 3 MB compile is a third of a frame on the thread that draws. Budgets arrive from the deployment with no default anywhere, parsed into three outcomes so a typo is not hidden behind the supported absent state, and the development set lives in a module two checks prove the bundler deletes | Accepted | Amends 146 (its init message shape, not its shared-compilation property); Amends 040 (clause 4’s cadence, resolved against ADR-146’s own conclusion); interacts with 053, 063, 096; introduces no Q-ID | | 152 | Descent Studio exists, and ADR-109’s Native AOT publish gate — named in its Enforcement and then disclaimed as “does not exist yet, because Studio has no code” — is now studio-ci.yml’s native-aot job, closing OI-V-04. Workspaces are served from a custom app:// scheme with one host per workspace, not file://: §2’s reason for Photino is 100% reuse of the client’s SolidJS/Babylon stack, and ES module scripts, module Workers and compileStreaming all fail from a file URL — so a file-served shell would be reusing the design and not the code; one host each means two workspaces are two origins with separate storage, because a shared origin would hand a verified-but-hostile module the others’ data. Module verification fails closed — a Launchpad with no trusted keys loads nothing, since treating “no keys configured” as “checking is off” disables the control on every machine nobody set up — and signatures are ECDSA P-256 over the manifest’s exact bytes rather than a re-serialisation, so a future JSON writer cannot invalidate every signed manifest in the field. Every gate rule is in a pure type taking fileExists as a parameter, because a rule reachable only by installing a bad module and watching a window is a rule with no test. The Launchpad-to-module payload is length-prefixed JSON: the framing is §1.6.3’s, and the payload is JSON because this is a control channel of kilobytes per build, FlatBuffers would put flatc’s six-mirror pin into Studio’s build, and §1.6.3’s own stated cost of the boundary is that “debuggability gets worse”. The gate does not stop at “it compiled”: --check runs the published binary headless and exits non-zero on a refused module. Measured 2026-08-11: Photino.NET 4.0.16 publishes with zero IL2xxx under every analyser and the running exe reports IsDynamicCodeSupported = False; and Uri.AbsolutePath normalises .., . and %2e%2e away, so the traversal refusal a first draft asserted could never fire — what survives is an encoded separator, and the suite now pins both halves. Hosting Descent.Sandbox in the Forge process is not done because the Wasmtime .NET binding’s AOT-cleanliness has not been measured, and asserting it without a probe is what P2 forbids | Accepted | Implements 109 (its Launchpad, its three artefact kinds and its IPC seam); Implements 148 (the Ruleset Forge workspace); closes OI-V-04; interacts with 098, 147; introduces no Q-ID | | 153 | §6.1’s asset decryption stays on crypto.subtle, and the WebAssembly core ships built, tested and off. The proposal was a Rust wasm32 +simd128 decoder in place of Web Crypto; it was built and then refuted twice over. +simd128 works and is nowhere near enough — measured against a build of the same source with the flag removed it emits 714 v128 opcodes of 17,235 and is 5.3% faster (4.738 ms against 5.012 ms, spread under 1% inside each arm), because LLVM does autovectorise the bitslice aes 0.9.2 falls back to — that crate gates its accelerated backends on aarch64 and x86/x86_64 with no wasm32 arm, and polyval 0.7.3 is likewise soft. Against crypto.subtle it is ≈19.5× slower (Q-100 4.738 ms against Q-099 0.243 ms for one Q-098 chunk), which at §9.6.1’s frame boundary is roughly one chunk per frame against thirty — §9.6.2’s low-LOD placeholder becoming the normal case rather than the degraded one. The reason is structural: WebAssembly has no AES instruction in the MVP, in fixed-width SIMD or in relaxed-SIMD, while crypto.subtle issues one silicon instruction per round — so 5% of a bitsliced software cipher is the whole of what the flag can be asked for. The first census reported zero and was wrong: cargo discovers .cargo/config.toml from the current directory and not from --manifest-path, so a build run from the repository root silently dropped the flag and wrote a different artefact to the same path. What the wasm core buys is a threat-model difference — §6.1’s own defeat clause moves from “replace one well-known global” to “locate a key in a stripped wasm heap” — and §6.1 trades in cost rather than prevention, so it is kept behind WASM_DECODER_PREFERRED = false with both arms under test, the shape WEBGPU_BACKEND_BUILT already uses. Its fallback role is nearly vacuous and the record says so: crypto.subtle is absent only outside a secure context, which this client already requires. The PoC also built §6.1’s streaming store, which did not exist — and ADR-060’s ciphertext only is now a compile error to violate (a unique symbol brand with one constructor) rather than a sentence. The shared two-decoder suite found its first defect immediately: the wasm host scrubbed the caller’s key buffer and the WebCrypto one did not, so two implementations of one port disagreed about whether an argument survives the call. Not done, and not to be generalised from: the Basis transcode — the one stage of §6.1’s pipeline where wasm SIMD genuinely pays — was not touched | Accepted | Depends-on 060 (session-scoped content keys, and the ciphertext-only OPFS store this decodes); Depends-on 064 (the capability injection point, which is why navigator.storage is not read by the store); interacts with 017, 144, 147; introduces Q-098, Q-099, Q-100 | | 154 | The SignalR hub protocol was JSON, nothing said so, and it was base64-encoding every FlatBuffers payload. The proposal was to replace “JSON serialization for SignalR” with Rust FFI + FlatBuffers; checking it found the payloads were already FlatBuffers (ADR-032) and the JSON was one layer up, in the envelope. AddSignalR() with no protocol configured registers the JSON hub protocol, which has no binary type — so every byte[] on RoomHub was base64, a 33% inflation plus an encode and a decode per message per viewer at Q-001. Two comments in signalr-transport.ts disagreed about which protocol was in use, and its header gave a reason for not wiring MessagePack (“the generation step does not exist”) that stopped being true when generate-protocol-ts.mjs landedP6 inside one file. Measured (tools/bench-hub-envelope.cs): MessagePack’s envelope overhead is a constant ~20 bytes at every size while JSON’s is proportional, converging on 4/3; at 50 viewers of a 100-entity tick, 273,600 B against 204,400 B — 25.3% of the delivery path, or 1.35 MiB/s per room at 20 Hz. The epic’s own question came back zero: both protocols allocate 0 B per message into a reused buffer, so the GC-pressure motivation does not exist and the case rests on bytes. Both protocols stay registered — a client negotiates, and dropping JSON would refuse every tab open across a deploy — and the client half is not optional, since a server that offers and a client that does not ask leaves every session on JSON with a passing test. The defect this found is Q-101: Q-056 bounds the payload and MaximumReceiveMessageSize bounds the encoded message, they had the same value, and a Q-056-sized payload encodes to 43,747 B under JSON and 32,796 under MessagePack — both over SignalR’s 32,768 default, so Q-056 has never been admissible inbound and the real ceiling was ~24.5 KB. descent-net-ffi is refused on structure: the source data is managed grain state, so a Rust builder adds two marshalling copies to a path whose entire performance claim is the absence of one; it would be a second codegen target for the .fbs set that §5a names as deliberately not replaceable; and it puts flatc’s six-mirror pin in a third place, which is ADR-150’s argument unchanged. Not done: no end-to-end session asserts the negotiated protocol on the wire, and the codecs’ own allocation is unmeasured | Accepted | Depends-on 092 (per-viewer targeted delivery, which is what multiplies the envelope overhead by the viewer count); interacts with 029, 032, 150; introduces Q-101 | | 155 | Descent.Geometry answers in three dimensions, and the 300 ms budget the proposal asked for is refused because a clock is not a function of the inputs. The new volume module adds a Panel — a plan-view segment extruded between two heights — and a VolumeBvh over it, so a sight line can pass over a crate and under a balcony. The primitive is a vertical quad rather than a triangle mesh because that restriction is what keeps the arithmetic exact: a vertical plane contains the world’s vertical axis, so the crossing is decided by the same orient2d the oracle already verifies and the height test is one comparison of two i128 products — no division, no plane equation, no barycentric coordinate, none of which survives fixed point without rounding. An octree was considered and refused: it subdivides space, which pays when occupancy is uniform, and a VTT’s occluders are walls clustered on a floor plan, so it would spend its depth splitting empty air. Decision 2 is the load-bearing one. A wall-clock deadline cannot be an input here: two hosts would cross a millisecond threshold at different moments and return different answers — 3D on one, projected 2D on the other — and ADR-017’s bit-exactness is the property the crate exists to provide. So the ceiling is Q-102, counted in node visits, which is Q-058’s precedent rather than an analogy; the host keeps its 300 ms outside the core where ADR-033’s dispatch-now surface already owns wall-clock. The 2D fallback is legitimate because of the DIRECTION of its error, which is proved rather than asserted: a projected panel blocks every line that crosses it in plan, including ones that in fact pass over or under, so the projection blocks a superset and visibility is a subset — it can hide something visible and can never reveal something hidden, which is the only direction §5.4 permits for a security property. the_plan_projection_never_reveals_more_than_the_volume is a randomised property test over that claim. A mutation sweep of twelve mutations caught twelve and found two things no correctness test could: an explicit sign-normalising branch that was dead weight (negating all three operands of a min/max bracket leaves it unchanged, so the branch was deleted), and that dropping the z axis from the BVH’s box test changes no answer at all — necessarily, since an acceleration structure may not. The visit budget is what made the missing assertion writable: the_z_axis_prunes_and_the_budget_proves_it states pruning as a deterministic work count rather than as a timing. Bvh is not retired, and full_height_panels_agree_with_the_two_dimensional_core asserts the two agree on a scene with no height in it. Not done: the C ABI and wasm bindings do not expose the volume API, Q-102 has no value, and no benchmark exists for 3D | Accepted | Depends-on 017 (one implementation, bit-exact across two hosts — which is what refuses the millisecond budget); Depends-on 034 (the disclosure asymmetry the new query preserves unchanged); interacts with 033, 056, 091; introduces Q-102 | | 156 | WebGPU compute feathers the authoritative mask; it does not compute visibility — and the two epics that proposed the latter meet a rule already in the crate’s header. ADR-017, restated in Descent.Geometry/src/lib.rs, names and excludes “no authoritative WGSL implementation”. The migration also has no subject: the Geometry Worker computes pathfinding, not raycasting, because four mechanisms agree that the client may not produce visibility — §9.2’s scope, the wire’s unconditional DISCLOSURE_PARTIAL, mask.ts’s type with “no way to construct it from geometry”, and fog.ts’s “there is no input from which it could”. A* is the actual workload and the GPU formulations of it are different algorithms, which is a second implementation by another route. What IS legitimate was already scoped by epic_presentation_smooth_fog.md §2B, and is what shipped: core/descent-fog-compute holds one WGSL distance-transform shader and one CPU reference, and tests/parity.rs executes the shader on every adapter the machine reports — five here including the software one — asserting byte-for-byte agreement, plus two static parses that pin the workgroup size and the binding set, which WGSL cannot report at run time. The inward-only invariant is an early return in both implementations rather than a property preserved by care (P3). The metric is Chebyshev, not Euclidean: sqrt’s last bit may differ between drivers, which would make a fog edge a function of the player’s GPU vendor against that document’s own cross-device parity requirement. The fallback is to STOP feathering, not a CPU path, and that is measured: hardware 0.30 ms per Q-011 chunk against a 7.55 ms CPU reference (45% of a 60 Hz frame, native — wasm is worse) and a software adapter at 48.70 ms, 6.4x slower than the CPU, so a software tier is strictly worse than no GPU. A lost device is a third state from an absent capability, because Guardrail 1’s Recovering state is a different sentence — the first consumer of §14.6’s device.lost fault. isInwardOnly also ships as a run-time check, because the producer is a driver and an invariant enforced only by CI on somebody else’s GPU is not enforced on the one that matters. A benchmark that was wrong first is recorded: timing device creation together with dispatch reported the GPU as 11–16x SLOWER, which is true of that code and false about the hardware. Not done: nothing consumes any of it — WEBGPU_BACKEND_BUILT is still false, so featherPlan has no caller and the WGSL is deliberately not copied into src/; the device-lost transition is unimplemented; and a browser cannot see its adapter’s tier, so the measured ladder is enforceable in the Rust host and not in the client | Accepted | Depends-on 034 (the client may not produce visibility, which is what makes a GPU workload here permissible at all); Depends-on 064 (the capability injection point and the device.lost fault); interacts with 005, 017, 035, 155; introduces no Q-ID | | 157 | RNNoise runs in the AudioWorklet and needs no SharedArrayBuffer; the device swap is a comparison of two lists rather than a reaction to an event. nnnoiseless — the Rust port of Xiph’s RNNoise with the published weights — compiles to wasm32 at 487 KB with zero imports, behind the buffers-and-offsets ABI ADR-153 established. The proposal’s SharedArrayBuffer requirement is refused: an AudioWorkletProcessor is handed its buffers inside the audio thread’s own global scope and the wasm module lives there too, so audio never crosses a boundary and there is nothing to share — gating on isolation would disable the feature on every deployment without COOP/COEP, against ADR-096’s own finding that its absence gates no capability. What isolation DOES decide is the telemetry channel, 375 voice-activity readings a second, which is presentationChannel’s trade in another subsystem. The native fallback is kept and retargeted to the three conditions that actually prevent the worklet running: no AudioWorklet, no payload, or a sample rate the model was not trained at — at 44.1 kHz the 480-sample frame is 10.9 ms and every band shifts 9%, so the guest exports its required rate rather than letting a host assume. The worklet plan switches the browser’s own suppression off, because two adaptive gates in series pump. The 128-to-480 reframing is the part that quietly does not work if skipped: a processor calling RNNoise per quantum feeds a 10 ms model a 2.67 ms window, compiles, runs, and denoises nothing — the failure reads as a bad model. It costs exactly one frame, 10 ms, asserted against an identity transform. Measured: 13.8 dB of broadband noise attenuated, 107.2% of clean signal energy retained (over 100% because RNNoise’s band gains are not capped at unity — reported as measured). A waveform SNR was tried first and is the wrong instrument: it read 13 dB WORSE, because RNNoise is not sample-aligned with its input and the fixture is not speech; searching for the flattering lag would have measured nothing, so two alignment-independent energy ratios replaced it and the negative result is kept. For the swap: devicechange fires more than once per physical event and describes nothing, so the handler is idempotent; a preference is a list, so unplugging a headset and plugging it back in returns the player to it; and the two halves fail independently, because being heard but not hearing is a different situation from neither. Not done, and it could stop this shipping: the 2.67 ms real-time budget is unmeasured, nothing has run on an audio thread, §7.1’s voice subsystem does not exist, and echoCancellation: false REMOVES a capability on devices without hardware AEC | Accepted | Depends-on 096 (the absence of cross-origin isolation gates no capability, which is what refuses the SAB gate); Depends-on 064 (the capability injection point the telemetry channel is resolved from); interacts with 081, 108; introduces no Q-ID; Amended-by 166 | | 158 | The 2D bake ladder’s bottom rung emits an image rather than an error, because ADR-005 makes the bake a registration GATE. tools/descent-asset-baker renders top-down tiles headlessly — no window, no surface, no swapchain — through hardware, then a software adapter, then a CPU-drawn labelled footprint. A baker that failed with no adapter would make the gate a property of the BUILD AGENT rather than of the asset: a bundle refused Profile C because a container had no GPU, with the author told their asset was wrong. So the placeholder rung emits a real PNG of the footprint — which is exactly what §2.2 already says a Profile C player sees for a bundle with no bake, so the pipeline’s fallback and the player’s degraded view are the SAME ARTEFACT. It is never silent: Bake::tier travels with the image, because a placeholder accepted as a render is the failure the rung exists to make visible, and the placeholder is hatched rather than flat so a player can tell them apart at a glance. A device that was FOUND and then FAILED does not fall to the placeholder — no adapter is an environment fact, a driver that accepted a device request and then failed is a fault an operator must see, and silently emitting a tile would turn one broken agent into a catalogue nobody noticed (ADR-156’s absent/lost distinction, elsewhere). Every adapter bakes the same tile, asserted across all five this machine reports — silhouette identical, no channel differing by more than 2/255, and NOT byte-for-byte, because rasterisation permits backends to disagree on pixels exactly on a primitive edge and demanding equality would assert a property WebGPU does not provide. The 256-byte readback stride is tested at 50 px deliberately: 64 px is 256 bytes exactly and would pass with the shear bug present. Not done, and it is the largest gap: there is no mesh loader, so the pipeline draws the footprint VOLUME rather than the asset — a right silhouette and a wrong interior — and no real bundle’s tile can be produced; nothing calls the baker, so ADR-005’s obligation is still enforced by nothing; and lavapipe is named and untested | Accepted | Depends-on 005 (the mandatory 2D bake and its registration-time refusal, which is what makes the placeholder rung necessary); interacts with 068, 156; introduces no Q-ID; Amended-by 171 | | 159 | A fuzzer’s budget is the security control; its mutator is a search heuristic — and AFL++ is not runnable here for reasons that are structural. tools/marketplace-wasm-fuzzer bombs an untrusted cartridge under an engine configured to ADR-140’s proposal set exactly, because a fuzzer running wider than production finds inputs that cannot occur and misses the ones that can. Two budgets, not interchangeable: fuel is deterministic and is the production budget, since a creator’s refusal must be reproducible; the epoch deadline is a backstop, because fuel does not bound time — a blocking host call or an engine pathology burns none — and the two outcomes are reported separately. ADR-155 refused a wall-clock budget and this accepts one: that ruling binds a deterministic core whose answers must match across two hosts, whereas a sandbox’s job is to stop things and a backstop that fires non-deterministically still stops them. Nine hostile modules, written in WebAssembly text so a reviewer can read them rather than committed as opaque blobs: infinite loop → out of fuel; memory.grow bomb → contained at the ceiling; unbounded recursion → trapped inside the guest rather than taking the host’s stack; a smuggled wasi_snapshot_preview1 import → uninstantiable against an empty linker, which is ADR-147’s rule enforced structurally; a v128 module → refused at compile time. A fresh Store per case, because a campaign whose cases are not independent reports findings that cannot be reproduced from the input that caused them. AFL++ does not run: its fork-server needs fork() with no supported Windows build, cargo-fuzz needs clang, and — the part worth carrying — even on Linux it instruments the HOST, so a campaign would be steered by coverage of wasmtime rather than of the cartridge. That costs discovery depth and does not cost the control. Two defects the corpus found here: a deadline ticker that made every invocation cost the full deadline (the suite HUNG rather than failed), and an error mapping that dropped wasmtime’s cause chain so a rejection said failed to compile: wasm[0]::function[0] instead of “SIMD support is not enabled”. Not done: no real cartridge was fuzzed, the generator bombs the ABI boundary and never ADR-145’s envelope payload, nothing runs it, and nothing checks that this engine configuration and WasmSandboxEngine’s agree | Accepted | Depends-on 140 (the sandbox proposal set this engine must configure identically); Depends-on 147 (the zero-import cartridge shape, enforced by an empty linker); interacts with 100, 155; introduces no Q-ID | | 160 | A hidden roll’s doubt is TEMPORAL, so commit-reveal answers it and a zero-knowledge proof does not. A ZKP lets a prover who holds a secret convince a verifier without revealing it, and is right when the prover is both the party you distrust and the party computing. Neither holds: ADR-016 makes RoomGrain authoritative and the dice come from DESCENT-DRBG-HMACSHA256-CTR-v1 inside the silo, so there is no client-held secret; and players already accept the server’s word on visibility, validation and every secret in the room, so proving a server assertion to them proves something to someone who has accepted strictly more. The real doubt is not “is the arithmetic right” but “was the seed chosen after the outcome was known to be convenient?” — which commit-reveal answers exactly, so the proposal’s fallback becomes the mechanism. core/descent-roll-commit publishes HMAC-SHA256(seed, DOMAIN then context) before the roll and the seed after. The seed is the HMAC KEY and the context is the message: committing to the outcome under a public key is a hash of a value with twenty possibilities, and the corpus carries out that attack against the wrong construction rather than describing it. The commitment binds room, sequence, sides and count, serialised fixed-width big-endian with no separators so two contexts cannot share a serialisation — without which one commitment could be reused for whichever later roll suited. Rejection sampling, not modulo, because x % sides is biased toward low faces whenever sides does not divide 2^32 — invisible in a session and wrong over a campaign — and deterministic so a verifier re-derives the identical path. The Plonky3 build question was probed and answered anyway, deliberately: there is no plonky3 crate (it is ~30 p3-* crates at 0.6.3) and they DO compile for wasm32-unknown-unknown on stable 1.97.1, while plonky2 needs nightly specialization and is disqualified by the toolchain pin alone. The refusal is architectural and not a build failure, and saying so is what stops the idea being re-proposed the moment someone checks. The 2000 ms question is not answered and cannot be by a build probe. Not done: nothing calls it — no hidden-roll command, no commitment on the wire, no client verifier, no wasm32 artefact; the reveal’s TIMING is unmodelled and is where the real design question is, since a GM who never reveals is indistinguishable from one whose roll was honest; and it does not stop a server choosing among many seeds before committing | Accepted | Depends-on 016 (the authority tier that owns the dice, which is what removes the client-held secret a ZKP would prove about); interacts with 041, 094, 137; introduces no Q-ID | | 161 | Tantivy is an ADDITIONAL T2 projection rather than a replacement, and embedding it makes ADR-043’s rebuild watermark PER-REPLICA. The proposal was to replace PostgreSQL JSONB queries; that word does not survive §2, where T2’s JSONB read models are what a sheet, a vault listing and a timeline read from — a search index finds documents rather than returning them. Correctly placed it is a second T2 projection for the queries JSONB answers badly (free-text relevance over notes, handouts and transcripts), and that is not a fourth source of truth: §2’s prohibition is about authority tiers, and a second T2 projection is still T2. It inherits every rule, held as code: search returns ids and a watermark and nothing else, because a payload carrying state invites a caller to act on it without a permission decision; SearchHits has no constructor omitting source_event_seq, so a result without a watermark is unrepresentable and even an empty set carries it (“no results at seq 30” and “no results, position unknown” are different answers); a rebuild deletes first, because a rebuild that merged would leave a document deleted from T1 findable, invisibly, until somebody searched for something that should not be there; and an event behind the watermark is refused rather than clamped, since clamping leaves the document applied and the watermark wrong. The finding is what embedding costs: ADR-002 makes the silo one deployable scaled horizontally, so an embedded index exists once per replica — two consecutive searches from one player can land on replicas at different positions and differ with no event having happened, a rebuild is N rebuilds, and memory is paid N times. Decision 3 makes that DETECTABLE and ADR-043’s own words then apply: detection is not protection. Not done, and the FFI half is the larger part: no C ABI, no [LibraryImport], and no LIFETIME model — an Index is a live object with an open writer and grains are virtual actors that come and go, while §5 makes Descent.Vtt.Geometry.Interop the only project permitted [LibraryImport]. No Marten integration, so the rebuild strategy is designed and not built; and no measurement against pg_trgm — the case rests on capability, which is said plainly rather than implying a benchmark | Superseded-by 168 | Depends-on 043 (the rebuild watermark, whose single-value assumption an embedded index breaks); interacts with 002, 016, 111; introduces no Q-ID; Superseded-by 168 | | 162 | Descent.RngKit and the C# ALC cartridge system are DELETED WITHOUT ARCHIVAL — 41 349 lines — because ADR-141’s recorded precondition is met. It said RngKit retires “when the C# cartridges themselves become WebAssembly components”, and ADR-149 built them. The DRBG is CARVED OUT of the deletion, and the override of an explicit instruction is argued rather than taken quietly: SeededDrbgProvider is not a ruleset-authoring API but the authority’s generator; it implements DESCENT-DRBG-HMACSHA256-CTR-v1, whose own remarks say changing it “invalidates every previously issued proof”; and ADR-149 decision 8 makes it MORE load-bearing after the cutover, not less — a wasm cartridge draws no dice and takes input.rolls from the authority that owns them, so deleting the generator in the same change that made every cartridge depend on being handed its dice would be two halves of one decision pulling opposite ways. It moves to core/Descent.Drbg with behaviour untouched, staying under core/ because ADR-111 makes that directory the licensing statement. ADR-141 clause 5’s prediction is CORRECTED: it expected the exemption to end “when one implementation ends” and the count to return to one; the C# side survived, so two implementations remain and WasmCoreDrbgParityTests is still a condition of admitting the second — it followed the type to a new namespace and was not regenerated from either side. A thin core/Directory.Build.props is added because Descent.Drbg is the first .NET project under core/ outside the two vendored trees, which without it would silently have lost TreatWarningsAsErrors — architecture-rules §6 names that failure in the abstract and this is its first concrete instance. Stated rather than hidden: for a period this repository has NO executable ruleset on the product path; the integration suites drive a copied test double whose arithmetic is not reproduced; and AlcCartridgeLoaderTests is deleted without a wasm analogue, because a .wasm module has no dependency graph to resolve and no context to collect | Accepted | Supersedes 134 (the ALC loader it deletes); Depends-on 141 (whose retirement precondition this satisfies for the authoring API and not for the generator); Depends-on 149 (decision 8: a cartridge draws no dice, so the host must keep the generator that does); interacts with 016, 017, 055, 111, 136; introduces no Q-ID; Depended-on-by 163 | | 163 | Wasmtime is the EXCLUSIVE cartridge host. WasmCartridgeLoader replaces AlcCartridgeLoader behind the unchanged ICartridgeLoader port, so Application never learns that loading changed; the signed manifest now declares the rulesets and actions the assembly used to expose, which is the better end of the trade because what a cartridge claims to serve is now signed rather than inferred, and a module whose digest is absent from the signed file set is refused as an unsigned payload selected by a signed document. Wasmtime’s on-disk AOT compilation cache is enabled through a generated TOML — files-total-size-soft-limit and cleanup-interval are keys wasmtime_config_cache_config_load parses, and there is no binding-level equivalent — and no C# deletes an entry: a .cwasm is mmapped by every store using it, so a C# sweep is a use-after-free in native memory on Linux and a sharing violation on Windows, one bug and two unattributable failures. TWO ENGINES DIFFERING BY FUEL ALONE: the untrusted meters against an injected budget, the trusted does not, and both inherit every other ceiling — a first-party cartridge gets an unmetered engine, never a bigger heap. The brief’s “extended WASI privileges” for the trusted engine is REFUSED against ADR-137 clause 3, whose zero-import check is the whole containment story and which a populated Linker would demote from a structural boundary to a reviewed one (P3). Unmetered is not unbounded, and that is why it is admissible: epoch interruption is independent of fuel and stays on in both, so what is lost is a deterministic bound rather than a bound — acceptable for a reviewed cartridge, which is exactly why it is not acceptable for a community one. The trust tier is HOST configuration and never a manifest field, because a publisher signs their own manifest and a self-declared tier is a self-granted privilege; an empty OfficialPublishers is safe and permitted where an empty TrustedPublishers is refused, since one is a trust anchor and the other a relaxation. Measured 2026-08-11 over 7 green tests: the binding exposes WithCacheConfig, the native library carries the cache keys, the generated TOML is accepted, the trusted engine executes unmetered and still stops a spinner on the epoch deadline, and neither engine admits an importing module. OWED, and the largest item: RoomGrain still calls IRulesetEngine.Execute synchronously in a mailbox turn, which architecture-rules §3 forbids twice over for a sandbox path (class 1 and class 4), so WasmRulesetEngine.Execute throws with a sentence naming the rule and nothing routes a room’s action to a cartridge yet — the same architecture-with-no-consumer shape R36 recorded against the plugin worker. Also owed: the schema is an empty vocabulary (fail-safe, nothing writable) and mutation decoding across the guest ABI is unimplemented. The macOS leg found a defect that is not the Wasmtime binding’s: the Jint engine’s 16 MB dedicated stacks exhaust a 7 GB arm64 runner under parallel collections, so that leg is scoped to the WebAssembly namespace and OI-V-08 records the rest (renumbered from OI-V-07 on 2026-08-12: that id was already cited by testing-and-verification.md §10 for the fog-advance completion gate, and two items briefly shared it) — a containment ceiling is not lowered to make a build green. The loader’s own refusals ARE held: 18 tests over the signature, digest, traversal, trust tier and lease lifetime, and a thirteen-mutation sweep in which two survived and were repaired — one because a urn test asserted NotFound and was green for the wrong reason, one because two refusal arms share a reason code and only their detail tells them apart | Accepted | Depends-on 162 (which deleted the only other loader); Depends-on 137 (the engine, and clause 3’s zero-import invariant this narrows the brief against); interacts with 020, 033, 100, 132, 134, 136, 147, 149; introduces no Q-ID; Depended-on-by 164 (which supplies the caller its host lacked) | | 164 | Cartridge execution leaves the mailbox by DISPATCH-AND-REENTER, closing the debt ADR-163 recorded — its host had no caller, because IRulesetEngine.Execute is synchronous and RoomGrain called it inside a turn, which architecture-rules §3 forbids twice over for a sandbox path (class 1 and class 4). The fork is decided by the engine’s TYPE: an engine that cannot answer in a turn implements IAsyncRulesetEngine, so the room’s behaviour and the engine’s capability cannot drift apart, and the synchronous arm SURVIVES for engines that can — making every action asynchronous would turn every unknown action and negative argument into an event a client must correlate, for no gain. The grain stays NON-REENTRANT and [Reentrant] is REFUSED although this round was empowered to use it: it relaxes exactly ADR-033’s “one message at a time, including across await points” that the tick depends on, to solve a problem dispatch solves without relaxing anything — what leaves the turn is a computation touching no room state, and every mutation still happens inside a turn. The actor is FROZEN at dispatch, which is the subtlest decision: Actor is mutated in place, so a live reference would let a cartridge read a revision the room reached AFTER dispatch and build a proposal whose premise matched BY ACCIDENT — ADR-046’s check passing on exactly the case it was written for, with no exception and no conflict. There is NO new ETag: ADR-046’s ExpectedRevision already is one and re-entry runs the same ProposeBatch, because a second staleness mechanism would be a second answer to one question. One invocation per ACTOR (not per room, since the premise is per actor), which is ADR-151 decision 2 one tier down. Dispatched is a new status rather than a reused Accepted, whose contract is “validated, applied in memory, and queued for persistence” — three facts, none yet true — and the ingress collapses the two only for the player, who cannot act on the difference. The outcome crosses back as an ID rather than a value, because RulesetOutcome holds SDK types and passing one would force an Orleans serializer onto a dependency-free public surface to move an object within one process. Measured: Grain.GrainFactory is ACTIVATION-SCOPED and throws “Activation access violation” from a thread-pool thread — every other dispatched stage resolves it while still on the Orleans scheduler, so the idiom looked safe to copy. AMENDED 2026-08-11: a deactivation mid-flight is mitigated by DelayDeactivation at dispatch, bounded by RoomTickOptions.CartridgeDispatchKeepAlive — and implementing it found that §4’s Dormant state DOES NOT EXIST IN CODE, so there was no “Active” to keep a room in; RoomTickScheduler ticks every registered room and nothing evaluates “no pending work”. A follow-up proposal to make DispatchedActorSnapshot a readonly record struct for zero-allocation was MEASURED AND REFUSED: 56.0 bytes/op either way, because ExecuteAsync takes an IActor and a value type passed as an interface is boxed — and a struct’s default would be a silently valid premise of revision 0, the one value most likely to match a fresh actor by accident. (Depends-on 163; Depends-on 046; Depends-on 033.) | Accepted | Depends-on 163 (whose owed work this is: its host had no caller); Depends-on 046 (the premise mechanism this reuses rather than duplicating); Depends-on 033 (the mailbox discipline that forces the shape); interacts with 016, 020, 132, 149, 151; introduces no Q-ID; Depended-on-by 170 | | 165 | Gas metering is the frontend plugin tier’s inner preemption and it costs NO import; Worker.terminate() is kept as the outer one; QuickJS is refused again. ADR-146 closed Spike S6 on the finding that no browser mechanism can interrupt a synchronous descent_invoke without an import ADR-144 refuses — true of the two it examined, and not exhaustive: both move information inward at run time, which is what an import is for. Metering does not. tools/wasm-gas rewrites a guest to carry a mutable i64 global the host writes BEFORE the call, decremented at every function entry and loop header, trapping on unreachable; the global is exported, and an export is outbound, so the module still declares zero imports and ADR-144’s one-artefact-two-hosts prize survives. Charging entries and loop headers intersects every cycle in the CFG, so recursion pays with no call-graph analysis. Measured on the real 3.03 MB Boa payload: instrumented in 157 ms, 12 955 charge points, zero imports, byte-identical output on a benign script, and a hostile while (true) {} traps with the budget exactly spent while the host runs the next script to completion. The brief’s removal of Worker.terminate() is OVERRIDDEN on P3: gas binds only what the rewriter touched, verifying arbitrary metering is a dataflow proof over hostile input, so the client instruments-or-refuses — which closes evasion but not a rewriter bug, and an in-band counter is weaker than a structural boundary. The two are layered, the outer kill becoming a last resort against a metering failure whose cost is every co-tenant. QuickJS is refused: ADR-144 already owns it (C, needs a libc wasm32-unknown-unknown lacks, so wasip1’s eight imports or emscripten’s glue), and JS_SetInterruptHandler has no object because the guest is a JS engine already — a hostile script’s loop is inside Boa’s dispatch, inside the metered module, so one mechanism reaches both tiers and is stronger than an interrupt for ADR-146’s own reason: it cannot be caught by what it stops. The World Builder’s JSON AST gets no new sandbox — a visual script is data in ADR-145’s input, executed by the same metered guest, because nothing about a node graph constrains what it compiles to. Not done: nothing consumes any of it; the supervisor still spawns one worker per plugin | Accepted | Amends 146 (its “no mechanism without an import” finding, and the role of its hard ceiling); Amends 040 (clause 1’s preemption gains an inner tier); Depends-on 144 (the zero-import invariant metering had to preserve); Depends-on 145 (the envelope field a visual script arrives in); interacts with 140, 159, 147; introduces no Q-ID; Depended-on-by 166 | | 166 | The Companion App is an ABSENT renderer, not a hidden one; a Discord Activity moves voice and never identity; form factor is not a capability. The <canvas> lives inside a <Show> keyed on presentation mode, because display: none looks identical in a screenshot and keeps a WebGL context, a Babylon Engine, a rAF loop and a texture working set alive on a phone — §9.6.2’s own “renderer-process crash rather than a slow frame”. Tabletop construction moved from onMount on App into a TacticalCanvas component, whose onMount fires after INSERTION and whose onCleanup disposes the engine with the element — without that teardown the Engine outlives its canvas and draws into a detached element for a 4–8 hour session. The ref callback was tried first and fails convincingly: a ref fires on creation, not insertion, so Babylon attached its pointer observables to a detached node — the table RENDERED correctly and every pick silently missed, failing thirteen existing specs with an empty lease-request array rather than with anything naming a canvas. The Companion toggle moved into TopBar for a sibling reason: at bottom-4 left-4 it sat inside SessionStatus’s panel and Playwright reported “subtree intercepts pointer events”. Enforcement is toHaveCount(0), the one assertion toBeHidden() cannot satisfy. Form factor is frozen beside the capability record and NOT in it (owner ruling): every CAPABILITY_NAMES entry obliges a Guardrail 7 cell describing a degradation, and a phone is not one — the cell would have to answer “what happens when handheld is absent”. The heuristic needs both a coarse primary pointer and a small viewport (width alone throws away a dragged-narrow desktop’s scene; pointer alone captures every touchscreen laptop) and measures the shorter side, since the value is frozen at boot and a width answer would depend on which way up the phone loaded. It decides a default only; the toggle ships on every device. Inside a Discord Activity capturesMicrophone is false — capturing would take the same microphone twice, set §7.1’s panner against Discord’s mixer, and double upstream — and it solves the corporate-firewall ICE failure. discordGrantsVttSession() is a named, tested false: a Discord grant authenticates the Discord account, and mod/infostealer/phishing compromise yields that identity with no password, so minting a VTT session from it would convert every such compromise into full control of campaigns and payment-adjacent surfaces. The COOP/COEP ruling needed NO new code, and that is the finding: typeof SharedArrayBuffer === 'undefined' is an ESLint error outside capabilities.ts, a second answer to what the frozen record holds, and a duplicate of crossOriginIsolation — already crossOriginIsolated && SharedArrayBuffer !== undefined. An Activity degrades through ADR-096’s existing path, and ADR-157’s worklet never needed SAB anyway. Cross-browser coverage is behavioural, and running it on the whole suite first found two things worth more than the coverage. F-R39-04: WebKit cannot load this client’s workers under the dev server — every failing spec touching fixtures.ts’s page-error assertion reports “Importing a module script failed”, and the client constructs six { type: 'module' } workers; scope stated rather than implied, this is pnpm run dev’s unbundled ESM and a production build may not reproduce it, which is untested. It is the round’s highest-value open item because iOS Safari is the only engine permitted on iPhone and Profile C’s premise is that mobile is downgraded rather than abandoned. F-R39-05: Firefox derives unitsToRaw(11) where Chromium derives unitsToRaw(9) from the identical drag — the elevation specs convert pixel deltas to whole cells against Chromium’s input model, and §14.9 makes a reproducible disagreement a finding rather than a retry. So Firefox and WebKit now name their two cross-engine specs with testMatch rather than subtracting the visual suite, making a new spec Chromium-only until deliberately added. gas-sandbox.spec.ts re-checks ADR-165 on SpiderMonkey and JavaScriptCore, where a settable mutable global and a catchable trap were specified but unverified. **The SDK is reached through a dynamic import() and the split is MEASURED: main chunk 1 200 201 bytes, the SDK in a separate lazily loaded 147 730-byte chunk, anchored on SPEAKING_START because DiscordSDK matches this client’s own destructure and discordsays its host constant — both present in main, neither meaning the SDK shipped. Not done: the handshake is untested against a real Discord client, NOTHING AUTHENTICATES (the VTT login screen does not exist, so the zero-trust ruling binds a screen not yet built), no AudioContext is opened anywhere, companion panels are placeholders, and ADR-005’s Profile C renderer is still unbuilt | Accepted | Amends 005 (adds a presentation surface above its profile ladder, and does not implement Profile C); Amends 157 (adds the question above its denoise decision, unchanged); Depends-on 064 (the injection-point discipline the device class copies); Depends-on 096 (the isolation-absent degradation an Activity relies on); Depends-on 165 (the gas semantics gas-sandbox.spec.ts re-checks per engine); Amended-by 167; interacts with 053, 061, 063, 123; introduces no Q-ID; Depended-on-by 173 | | 167 | Spatial audio inside a Discord Activity is refused on PLATFORM grounds rather than preference; the speaking ring is gated on the disclosed set; and player-voice panning is scoped under ADR-091. ADR-166 ruled capturesMicrophone: false on three tradeable grounds — double capture, panner-versus-mixer, doubled upstream — and a reader could answer each with “so do it carefully”. Both alternatives are unavailable, which is a different sentence. setUserVoiceSettings, setVoiceSettings and getVoiceSettings were removed from the Embedded App SDK (v1 migration guide, Removed Commands, with VOICE_SETTINGS_UPDATE and VOICE_CONNECTION_STATUS); the Commands enum carries no per-user voice write and src/commands/ has no voice module; what survives — VOICE_STATE_UPDATE, SPEAKING_START, SPEAKING_STOP, and a UserVoiceState whose volume and mute are observations — is read-only. And an Activity has no WebRTC at all (“WebRTC is not supported”; websockets only, all traffic through <application_id>.discordsays.com), so ADR-012’s own SFU path is equally closed — the fact with the widest blast radius, because it shuts both doors for different reasons. Discord’s own proximity guidance routes the feature through the native Social SDK (StartCallWithAudioCallbacks), unreachable from a web iframe. rpc.voice.write is refused as a remedy: it governs the native RPC transport and approval cannot restore a deleted embedded command; WebTransport is refused as a category error, being a data transport that would carry no media track. The speaking ring is sanctioned and its input is the disclosed set — nothing renders for an undisclosed speaker (ADR-091 clause 5’s fail-safe in its degenerate binary case). It is sound because it is binary, so there is no precision to invert into a distance, and because it operates no channel Discord does not already operate — Discord’s voice panel shows who is speaking regardless, so the ring binds speaking to a position the viewer already holds. Player-voice panning may use only disclosed coordinates, never a last-known position nor §5.1.1’s advisory uplink, and the fail-safe direction is FLAT rather than muffled — the counter-intuitive half and the point: muffling is the encoding, a flat voice states nothing about where its speaker is and a muffled one states a distance, so atmospheric occlusion of a concealed speaker is available only through ADR-091’s server-computed pre-attenuated aperture event and is not a client-side effect. This scopes rather than deletes F-R40-01’s four locations; a disclosed party at one table is unchanged. Not enforced, and therefore descriptions under P4 until their consumers exist: clauses 2 and 3 have no subject — no ring, no AudioContext, and ADR-157’s “§7.1’s voice subsystem does not exist” still holds — so only clause 1’s re-grounding of the existing voice-plan.test.ts assertions is enforced today. Clause 1 rests on a third-party surface and its re-check trigger is two observable events, not a date: a release note restoring a voice-write command, or the networking guide ceasing to say WebRTC is unsupported | Accepted | Amends 012 (narrows its record’s client-side attenuation sentence to the viewer’s disclosed set); Amends 166 (re-grounds its voice clause from preference to platform prohibition, and decides the speaking ring its SPEAKING_START reference anticipated without deciding); Depends-on 091 (the server-authoritative audibility rule, its clause 3 quantisation argument and its clause 5 fail-safe direction, applied here to player voice); Depends-on 034 (the single-producer visibility rule and the disclosed set that is the only permitted input to clauses 2 and 3); interacts with 157, 064, 005; introduces no Q-ID | | 168 | The §7.2 retrieval corpus moves OUT of the silo process into Meilisearch, embedded locally by a CPU Hugging Face model, and the browser queries it with a short-lived tenant token whose filter is the caller’s entitlements. ADR-161 accepted an embedded index and recorded what embedding costs: ADR-002 makes the silo one deployable scaled horizontally, so an in-process index exists once per replica — “two consecutive searches from one player can land on replicas at different positions and differ with no event having happened”, a rebuild is N rebuilds, and memory is paid N times. That finding is the reason for this decision rather than an objection to it: one index outside the process removes all three, and ADR-161’s own diagnosis is retained in full — a search index finds documents and is still T2, a search returns ids and a watermark and nothing else, a rebuild deletes first, and an event behind the watermark is refused rather than clamped. The embedder source is a LITERAL and there is no configuration path to a remote one, which is the whole of the zero-leakage claim and is worth exactly that: the engine supports OpenAI, Ollama and a generic REST embedder, and configuring any of them would ship every chunk of licensed and user-authored text to a third party at index time. Retrieval is filtered by ADR-097’s entitlement surface and never by §8.2’s Visibility Channels — §7.2 makes entitlement the corpus predicate in as many words, and ADR-097 clause 4 governs world disclosure, so the two paths are asserted unable to reach each other rather than merged. A caller entitled to nothing gets no token at all: TenantSearchRules has no constructor that produces an unfiltered or empty rule set, because every spelling of a match-nothing filter needs a value the corpus is assumed never to contain, and that is a claim about data rather than about code. The cost, stated rather than glossed: the engine validates the token and not the entitlement, so a revoked licence stays searchable for up to Q-104 — the token IS the cache ADR-097 forbids elsewhere, bounded instead of removed. §10.1’s “without a second search tier to operate” is narrowed by this row and pg_trgm keeps the queries it already serves | Accepted | Supersedes 161; Depends-on 097 (the read-only entitlement surface a tenant token’s filter is derived from); Depends-on 043 (the rebuild watermark every T2 payload carries); Depended-on-by 169, 172; interacts with 002, 042, 122; introduces Q-103, Q-104, Q-105 | | 169 | CDC is an Npgsql logical-replication consumer over an EPHEMERAL outbox — the row is inserted and deleted in one transaction — and every key in the CQRS/CDC domain is UUIDv7. No Debezium and no Kafka: what a heavyweight stack buys is fan-out to many consumers and replay independent of the database, and neither is needed by a pipeline whose one consumer’s upsert is idempotent. The outbox is ephemeral because logical decoding reads the write-ahead log rather than the heap, so the consumer receives the insert with its payload while the table never holds a live row; the publication publishes inserts only, so a delete is not decoded at all and the consumer cannot mistake a tombstone for a chunk. The insert and the delete are ONE command text, so there is no code path that inserts without deleting — a data-modifying CTE was written first and is refused on record, because every statement in one shares a snapshot and the delete therefore removes nothing while reporting success. Three residual costs are recorded rather than removed: the row still becomes a dead tuple autovacuum must collect, the payload is still written to the log, and a replication slot with no consumer pins log indefinitely — the last is the one that fills a disk, and it is why the slot is created by the consumer rather than by the migration and why vtt_knowledge_cdc_slot_retained_wal_bytes reports a negative sentinel for an absent slot instead of the zero a healthy one reports. Auto-increment primary keys are PROHIBITED in this domain: an identity column is a per-database sequence, so two regions accepting writes issue the same integers for different rows and nothing detects it until the halves are compared, at which point neither side is wrong. The prohibition reaches db/vtt and deliberately not db/marketplace, whose four identity columns include a published SSE Last-Event-ID; retrofitting them is a cross-context migration with a wire contract in it and is scoped out rather than assumed covered | Accepted | Depends-on 168 (the corpus this pipeline fills, and the only consumer that exists); interacts with 002, 042, 111; introduces Q-107; Depended-on-by 172 | | 170 | An AI session is a stateful Orleans grain that PROPOSES and never applies, and its proposal reaches ONE game master rather than a room. The R40 brief asked the grain to apply the game logic and broadcast the resulting state changes to every client in the room; both halves are refused on grounds this repository already carried. Applying is refused by §7.2’s GM Authority Principle, which is unqualified — “AI provides rule references and exact text citations only. AI NEVER forces world state mutations. Final ruling authority belongs 100% to the Game Master” — so a grain that mutated on a model’s say-so would be the authority and the game master a spectator with a veto nobody asks for. Broadcasting is refused by §8.2, and that half is a disclosure defect rather than a governance one: a proposal names an actor and a hit-point change, and a room-wide send tells every player that the actor exists and was targeted, including the players for whom it is not disclosed at all. Both refusals are held as types — AiCombatAction has no verb, the sink takes an ISingleConnectionSender and never an IHubContext, and the AI layer cannot name IRoomGrain — so the rule is a build failure rather than a review comment. What the brief wanted still happens: the game master accepts a proposal, which is an ordinary command through RoomGrain, and the change reaches every client over SignalR through the path that already applies per-viewer disclosure. The completion is dispatched off the mailbox and re-entered, ADR-164’s shape unchanged, because a model call is ADR-033 class 3 work. The only provider is an offline mock and nothing here can reach a model, so ADR-122 clause 4’s zero-retention obligation has no object yet — which is stated rather than treated as discharged. What is absent and named: there is no token quota. §10.3 makes per-room and per-player consumption quotas the defence against prompt spamming; one in-flight turn per session and Q-106 bound concurrency and conversation length, and neither bounds rate | Accepted | Depends-on 033 (the mailbox discipline a model call obeys by being dispatched); Depends-on 164 (the dispatch-and-reenter shape reused rather than reinvented); Depends-on 122 (the AI covenant that governs the day a real provider is configured); interacts with 016, 092, 168; introduces Q-106, Q-108, Q-109 | | 171 | ADR-158’s mesh loader is built, the ladder moves to the CREATOR’S MACHINE, and the brief’s AssemblyLoadContext mechanism is refused on a measurement rather than a preference. ADR-158 named its own two largest gaps — no mesh loader, so the pipeline drew the footprint VOLUME rather than the asset, and no caller, so ADR-005’s registration gate was enforced by nothing — and both close here. The brief names the wrong crate (F-R40-03): descent-asset-decoder is §6.1’s AES-GCM streaming core, already inside the browser’s Streaming Worker, and moving it to Studio would BREAK §6.1; the crate with the gap is descent-asset-baker. Node transforms are applied, which is the half a naive importer skips and skips quietly — right for a single-node asset, wrong for every kitbash, with the tile still rendering. The tile is depth-tested, because without it the last triangle submitted wins every pixel and the bake shows whichever part came later in the glTF’s node order. “Collision mesh” means the TOP-DOWN OCCLUDER HULL in Q-037, not a 3D decomposition: Descent.Geometry is the only thing that answers a collision question and it is 2D, so a 3D hull is a correct answer to a question nothing asks. The quantisation happens BEFORE the hull, because quantising after can round three nearly collinear vertices into a reflex turn and hand every consumer a polygon labelled convex that is not; quantising first makes it exactly convex on the grid the engine uses, evaluated in integers with no epsilon. The convex loss OVER-occludes, which is ADR-091 clause 5’s safe direction. Probes carry SKY VISIBILITY and not irradiance — the baker has no lights and the asset will stand in a room the pipeline never saw, so the occlusion half is the half that survives the move, and baking a room’s lighting into a reusable asset is a failure that looks correct in the room it was baked for. Directions come from a fixed Fibonacci sequence, because the ingestion gate re-derives the bake and a non-reproducible artefact makes that comparison unwritable. asset_bake.fbs is the tenth schema and the first generated for THREE languages, and the Rust arm sits in TypeScript’s position rather than C#‘s: generated C# asserts the flatc version and generated Rust does not, so a skew there compiles and decodes incorrectly — which is why build.rs checks the pin. The AssemblyLoadContext mechanism cannot execute: measured under the Launchpad’s own property set, IsDynamicCodeSupported = False, both LoadFromAssemblyPath and Assembly.LoadFrom throw PlatformNotSupportedException, and both raise IL2026, which IlcTreatWarningsAsErrors turns into a build error — so the Launchpad cannot compile the call and could not execute it. Studio_Architecture.md §1.6.4 PREDICTED this re-proposal in as many words, and the requirement behind it — a downloadable workload with full GPU and disk access — is already met better by §1.6.1’s child process, which also buys crash isolation and cancellation. The module is a Rust binary speaking the C# seam’s framed JSON, the first exercise of the “rewritten in another language” property §1.6.3 specified and nothing had tested. Block compression is NOT implemented and what that costs is VRAM rather than bandwidth — landing on Q-033, a budget that does not exist. Not done: no texture is read out of a glTF, nothing is timed, and the mesh pass has not been re-checked across adapters | Accepted | Amends 158 (closes its mesh-loader gap and gives its ladder a caller; the placeholder rung and the cross-adapter argument are unchanged); Depends-on 109 (decision 3’s compute-module kind and §1.6.3’s framed-stdio seam, whose language-independence this first exercises); Depends-on 005 (the mandatory 2D bake and its registration-time refusal); Depends-on 017 (the Q-037 domain the hull is quantised into); interacts with 091, 147, 156, 172; introduces no Q-ID | | 172 | The creator’s machine chunks §7.2’s corpus and asserts NO provenance; an ONNX model is admitted by hash or not at all; and the Honest Threat Model becomes a build gate. The R40 brief instructs the client-side baker to upload extracted chunks straight into PostgreSQL. Refused on ADR-098, which this repository already carried: Descent Studio is an untrusted producer; the ingestion gate is the trust boundary, and §6 of Studio_Architecture.md says the gate assumes nothing about the client that fed it. The consequence is concrete rather than procedural — a KnowledgeChunk has six fields and a submission may carry two, and of the four the gate derives, licenceId is what ADR-168 clause 3 makes retrieval’s ENTITLEMENT FILTER: a creator who could name it would write text into a licence they do not hold, and every player entitled to that licence would retrieve it as material the platform had vouched for — a cross-tenant corpus injection with a citation attached. sourceEventSeq is §2 rule 2’s rebuild watermark and a client-chosen 0 sits ahead of every rebuild; chunkId is UUIDv7, whose leading bits are a clock the platform does not own; ingestedAt is when the CORPUS accepted the text, which is the interval a takedown is argued over. The refusal is structural: CreatorChunkSubmission is a record with two properties and a test fails if a third appears. What the brief was right about still happens — splitting a 900-page rulebook is CPU work with no trust content, and it moves off the silo. Boundaries are preserved in a stated order (heading, then paragraph, then sentence, and only an oversized SENTENCE is cut, with the cut reported), because §7.2 promises exact citations and a chunk beginning in one section and ending in another cannot be attributed. A model is a PROGRAM: ONNX resolves external-data paths named inside the graph, resolves custom operators by loading a shared library, and parses attacker-shaped protobuf natively in-process — so LocalInferenceSession has no constructor and no overload without a digest, and the scope is stated rather than overclaimed, since a pinned malicious model is admitted and what is removed is the file changing between the decision and the load. §6.1’s Honest Threat Model gains its P4 enforcement point: three documents forbade an anti-tamper wrapper and nothing checked, which architecture-rules.md §9.6 lists as a recurring failure mode by name. The gate reads restored packages and post-publish CI commands, and has no per-line escape marker — the first draft did, and an exemption anyone can take is how a MUST gets demoted. Its own suite found two misses in the first matcher, both the most likely spellings: vmprotect_con.exe and DENUVO_KEY. Not done: the gate has no HTTP endpoint and no caller, no PDF is parsed, the AI module answers no prompt (generation needs OnnxRuntimeGenAI), and the Velopack packages are unsigned, which raises SmartScreen and is refused by Gatekeeper. “Llama-3-8B-Q4” is not an SLM and the corpus does not repeat the label: 8 B at 4-bit is ~4.5 GB before the KV cache, and small is what makes “runs on the creator’s laptop” sound free | Accepted | Depends-on 098 (Studio is an untrusted producer and the ingestion gate is the trust boundary — the whole of the refusal); Depends-on 168 (the entitlement filter licenceId becomes, which is what makes a client-asserted one an injection); Depends-on 169 (the outbox this gate publishes to, its UUIDv7 rule and its Q-105 bound); Depends-on 122 (clause 6’s publish-time AI disclosure, which already covers the text this pipeline moves); interacts with 109, 111, 171, 173; introduces no Q-ID | | 173 | LAN Play is the one Studio module that is NOT Native AOT, because Orleans measurably cannot be — and UseLocalhostClustering is a membership provider rather than a safety property. Measured 2026-08-12 under the Launchpad’s exact property set, Orleans 10.2.2 behind WebApplication.CreateSlimBuilder produces 171 IL2xxx/IL3xxx diagnostics (60 × IL2026, 56 × IL3050), which IlcTreatWarningsAsErrors makes 171 errors; with those waived the publish SUCCEEDS at 26.9 MB and the binary fails at startupa suitable constructor for OrleansCodeGen.OrleansRuntime.Metadata_OrleansRuntime could not be located, the trimmer having removed the constructor of Orleans’ own generated metadata. That is a publish that succeeds and a binary that runs are different facts for the THIRD time in this repository. What is given up is startup and not the deployment model: ADR-109 §2 takes AOT for no-runtime-install and startup, SelfContained keeps the first, and the second is paid by a background process a game master starts once per session rather than by the window they open to get somewhere else — 90 MB against 26.9 MB, recorded rather than glossed. §1.6.2’s prohibition is untouched, because it forbids a managed assembly loaded IN THE LAUNCHPAD and this is a separate process with its own runtime. UseLocalhostClustering says nothing about where Kestrel listens: it configures a one-node cluster with a development membership table, and the brief puts the two in one sentence — which is how localhost clustering comes to read as a safety property. A wildcard bind is REFUSED and not warned about: a LAN room is an Orleans world holding a game master’s whole campaign, including everything §8.2 conceals from the players in it, and 0.0.0.0 on a laptop is the hotel Wi-Fi, a VPN tunnel to an employer’s network, and whatever a docking station is plugged into. LanBinding has no factory that produces one, because a boolean flag defaults and this is the setting that must not. Joining needs a per-session 128-bit secret, compared in constant time, on ADR-166’s own ruling that reaching a service is not an identity — and the limit is stated rather than implied: it proves a player was TOLD the room exists, nothing more, and it travels over plain HTTP because a self-signed certificate trains users to click through the warning it exists to raise. The secret’s decoder was found accepting a 27th character, since one extra base32 symbol adds five bits without completing a seventeenth byte; malleability rather than a bypass, fixed by checking the length and the padding bits. Not done: no player can join — there is no room, no grain and no hub behind the endpoint — the module is not packed as a workspace, SQLite persistence is not built, and the three non-Windows AOT rows have never run | Accepted | Amends 109 (narrows §1.6.1’s second row: a compute module is a self-contained NATIVE executable or, where a measurement forbids AOT, a self-contained JIT one — §1.6.2’s managed-assembly prohibition is untouched); Depends-on 002 (the rule that a cluster is replicas of one binary, which makes a single-node silo coherent rather than degraded); Depends-on 166 (the ruling that reaching a service is not an identity, applied here to network adjacency); interacts with 016, 034, 091, 171, 172; introduces no Q-ID | | 174 | Supply chain controls: build provenance is UNREACHABLE on this plan, scanning is not, and the division of labour between the scanners was decided by measurement rather than by design. This platform signed its creators’ artefacts, distributed third-party UGC and executed untrusted WebAssembly in two hosts while producing no SBOM, scanning no dependency for advisories, and running no automated dependency updates — and github-workflow.md §5 asserted that “CI fails the build on a security-tagged advisory”, which no workflow has ever done (F-R42-04). Four controls land in the tiers P3 ranks: Renovate reports and merges nothing, because a dependency here is routinely a determinism surface or a native library measured under AOT and a version number describes neither; cargo-deny, osv-scanner and a NuGet wrapper gate. dotnet list package --vulnerable EXITS 0 ON FINDINGS, so the .NET third is a wrapped script whose eighteen tests include six that spawn it as a process — the exit code is the entire product, and a parser that is right about a gate that never turns red is the tee defect docs-lint.yml shipped for its whole life. cargo-deny does NOT run advisories: 0.19.1 panics parsing the current RustSec database on all eleven manifests, so osv-scanner covers that axis and cargo-deny is kept for licences, which osv cannot see at all and which ADR-111 makes load-bearing — Rust links statically, so a copyleft transitive is an obligation on a proprietary binary, and the allow-list fails closed where a deny-list passes an unfamiliar identifier. The baseline was measured before anything was gated and it split the decision: NuGet had zero findings across four solutions so it gates hard with no ignore file — and its FIRST LIVE RUN then failed on GHSA-q939-rpr3-3284, a HIGH-severity SSH.NET advisory published fourteen minutes earlier, which is the cleanest demonstration available that a baseline is a reading with a timestamp rather than a standing fact; the remedy was one PackageVersion for a package no project references, pinning the transitive through the flag ADR-176 had turned on hours before for an unrelated reason, since Testcontainers had not moved and no upstream bump existed to take, while crates.io had three advisories over two packages, none fixable here (lrutantivy, pasteboa_engine) so it gets a two-sided ledger in which a line that no longer fires is also red. That run also caught the configuration being wrong — allow-wildcard-paths = false failed 2 of 11 manifests on intra-workspace path dependencies, which would have been the always-red build produced by the file written to prevent it. The LTS directive is ENCODED, not remembered: allowedVersions: '<11.0.0' on the four families version-aligned with the runtime major. Not done: artifact attestations require GitHub Enterprise Cloud for a private repository and this organisation is on free (F-R42-01), so no build carries provenance and the SBOM is unsigned and unbound to a build; the Security tab needs Advanced Security and returns 403 (F-R42-02); Scorecard is declined because both publication routes need a public repository; cosign keyless was refused on DISCLOSURE grounds — Rekor is a public append-only log and the certificate carries the repository URI, workflow path and commit SHA; and Renovate is configured but not installed, which is an owner action | Accepted | Amends 111 (gives its proprietary-licensing decision its first enforcement point — a copyleft transitive entering a core engine now fails a build rather than a licence review); Depends-on 110 (the pnpm-only rule the scanners and the bot must honour, since tools/check-package-manager.mjs refuses any other installer); Depends-on 136 (the creator-payload provenance this is deliberately NOT — two signatures, two trust roots, two questions); interacts with 017, 137, 149, 162; introduces no Q-ID; Depended-on-by 175, Depended-on-by 176 | | 175 | The CRA technical half is buildable and was built; the legal half is written as QUESTIONS; and the deadline that commissioned it does not bind this platform — established by one command rather than argued. The brief treated 2026-09-11 as a thirty-day deadline. gh release list and git tag --list are both empty: nothing has ever been released, and CRA obligations attach to a product placed on the market. What survives is why the work was still done first — the obligation applies from the first shipment with no grace period, and a disclosure policy and an SBOM must already exist on the day a creator downloads a binary. The acknowledgement window is 72 hours and not 24, deliberately: a window is a promise that a human is available, and a policy with an unmonitored inbox is worse than none, because it converts a researcher’s goodwill into a public disclosure when nobody answers. The 24-hour figure is a different clock — manufacturer-to-authority, for a vulnerability actively exploited in the wild — and the runbook’s §1 keeps them apart, because conflating them fails in both directions. F-R42-03: the ONE disclosure route this repository publishes does not exist. core/Descent.Sandbox/SECURITY.md directs researchers to GitHub private vulnerability reporting, which is public-repositories-only; the API returns 404. It was written when the sandbox was a standalone public MIT module, and neither the convergence nor ADR-111 re-read the section describing how a stranger reports a vulnerability — a route that reads as available and is not is how a report is dropped silently. The support period is a proposal with a CONSTRAINT rather than a question: net10.0 is supported to November 2028, so 2028-11-30 is what the runtime allows, and the LTS directive tightens it because .NET 11 is STS and ends on the same date. The safe-harbour text and the support periods were DRAFTED BY AN AGENT and carry a banner saying soP9 applies to who wrote them, because a researcher reading a published policy reasonably relies on it. Not enforced, and therefore description under P4: nothing in this repository can observe whether the contact inbox is read, so the 72-hour window rests on a person rather than on a control; a dedicated security@ alias is recorded as an option and was NOT made a blocker. Not done: the runbook has never been executed and its tabletop has not been run; security.txt serves nothing because no public surface exists (OI-V-15); SBOM retention is 90 days against the CRA’s ten years (OI-V-14); and no counsel question has an answer | Accepted | Amends 098 (extends its untrusted-producer framing outward — the platform now publishes what it will and will not do when a stranger reports a defect in what it admitted); Depends-on 174 (the SBOM, the scanning and the dependency ledger this policy points a reporter at); Depends-on 111 (the proprietary posture that removed the public repository this platform’s one published reporting route depended on); interacts with 109, 122, 136; introduces no Q-ID | | 176 | The deferred verification pass was run, and it splits: central transitive pinning is ADOPTED, NuGet lock files are REFUSED as committed files — on a measurement about the RID matrix rather than on caution. Directory.Packages.props had recorded since 2026-08-08 that turning on CentralPackageTransitivePinningEnabled “would need its own verification pass against the parity corpus and the Native AOT publish gates”, and it had never been scheduled. ADR-174 gave it a reason: both osv-scanner and syft read packages.lock.json for .NET, and with none they see NOTHING of the .NET graph — syft resolves 1,523 cargo and 1,175 npm components and zero NuGet. Measured 2026-08-12: restore clean with no NU1109, so nothing was downgraded; build 0 warnings and 0 errors; 978 tests passed including the Testcontainers suite; and both AOT gates published clean — Descent.Marketplace.Api at 33.6 MB with IlcTreatWarningsAsErrors on, and Descent.Studio.Launchpad generating native code. The deferral named the parity corpus, and checking rather than assuming showed it was never the binding obligation — that corpus is a cargo suite with no NuGet graph, so it was discharged by establishing it does not apply, which is a P2 correction to a three-day-old note. What the flag does is narrower than “promotes every transitive to a direct pin”: it pins a transitive only where a PackageVersion already exists in a hub, 251 promotions, so a transitive this file does not name is untouched. Lock files are valuable and still refused: with them osv reads 124 packages in the silo alone and syft resolves 317 NuGet components, but a RID-specific restore ADDS a net10.0/<rid> section — the Launchpad produced ['net10.0','net10.0/win-x64'] — and studio-ci publishes two RIDs while marketplace-ci publishes a third, so a lock committed from any one machine carries that machine’s section alone. Under --locked-mode the other legs fail; without it restore rewrites the file and CI carries a dirty tree, which is a lock file that locks nothing. They are generated inside supply-chain.yml and thrown away instead. The trigger that reopens it is a single-RID publish path, or a restore that can populate every RID section from one host. Consequence stated rather than glossed: Directory.Packages.props is now load-bearing in a second way — a version there decides transitive resolution too, so a bump has a wider blast radius than before | Accepted | Depends-on 174 (the SBOM and the scanners whose .NET blindness is the reason this pass was finally scheduled); interacts with 109, 111, 173; introduces no Q-ID | | 177 | The measurement rig exists, §5.2 Segment B’s 5ms budget HOLDS with 7.4x headroom, and the sentence explaining WHY it holds is refuted. BENCH-01, 02, 03 and 05 had blocked sixteen rows since Quantity_Registry.md was created, and there was no rig at all — no BenchmarkDotNet outside the vendored sandbox, no criterion, no load generator, and every geometry figure in the corpus taken once by hand on one machine. Segment B measures 0.675ms against a ⊙3.04ms derivation: 0.22x, which is outside the pre-registered 0.5x–3x band on the side the criterion wrote no action for. Not one of the four measured terms was within a factor of two of its estimate. Three of the seven derived terms cost work that DOES NOT EXIST (F-R43-03: no bucketing step, no §10.4 digest, no §9.6.3 allowlist) and are left unmeasured rather than approximated. Segment B is reported twice because Table A budgets MAILBOX OCCUPANCY and the derivation costed the WORK — ReplicateToViewers ends at _ = SendDeltas(…) and the encode runs in the transport sink, so 0.332ms of the 0.675 is in the tick’s own frame; whether the continuation still occupies the activation is an Orleans scheduling question a harness cannot settle and is recorded as open. §5.2’s “the dominant cost is viewers, not world complexity” is FALSE (F-R43-04): geometry outweighs per-viewer assembly by ~19x in the other direction, and half that evidence has been in the corpus since 2026-08-02 with nothing re-reading the sentence resting on it. Q-016’s composition is refuted — it gave Segment B ⊙95% of a 50-viewer room and Segment B is 17% — and its total is deliberately NOT re-derived, because the geometry term is an admitted allowance rather than an expected cost and publishing one would repeat that entry’s own lesson that a budget ceiling is not a capacity unit. D-2 survives narrowly: 3.62x–4.57x against a <3x demotion threshold, and a prediction of 5x–25x that was optimistic by 2x. Q-007’s 1,000 holds at 0.67µs per Effect against ⊙3µs. D-4’s ⊙0.3ms intent is 0.394µs in the application layer, so Q-112 is registered Normative as a lower bound — Orleans’ dispatch is unmeasured and at Q-111’s seat counts it is the term most likely to bind, which is why Q-111 is SEGMENT_B_ADMITTED_SEATS paired with its entity bound and not a seat ceiling. Ratios are gated and absolutes published, on Q-075’s own reasoning that the multiplier transfers between machines and the milliseconds do not; the ratchet is two-sided and weekly, never on a PR (§14.8). Three figures reproduce: Q-075’s 12.99ms at 13.065ms, Q-058’s 6.1ms at 5.21ms, and ADR-143’s 5.32x at 5.47x. Not done: no load generator, so Q-002, Q-005 and Q-008 stay ⊙ on dispatch rather than on a BENCH item; Q-033 needs real hardware because no web API reports resident VRAM; the fog-cost model’s coefficients do not transfer between machines; Q-075’s multiplier re-measures at 1.25x against 1.4x and the ladder is NOT re-derived on one disagreement; and Segment B’s 1.42MB per tick is a garbage rate §5.2 does not budget | Accepted | Amends 046 (Q-007’s ceiling is now measured rather than assumed, and the batch is shown to be linear over three orders of magnitude, which its derivation assumed); Amends 143 (its 5.32x becomes a reproducible figure with a band rather than a single reading); Depends-on 033 (the dispatch-now/collect-next-tick surface the interop arm measures both sides of); Depends-on 023 (the baseline layout BENCH-02 exists to test); interacts with 017, 045, 092, 106, 176; introduces Q-110, Q-111, Q-112 | | 178 | The docs platform becomes the corpus’s cross-DOCUMENT gate, because the five existing lints each read ONE FILE and the corpus’s worst defect spans two. doc_reference_lint.py resolves references inside docs/ and the other four read the whitepaper; none can see two documents that agreed when written and silently diverged, because neither document is wrong on its own. Six checks, each a drift this corpus recorded catching BY HAND: the record-location table against the directory listing (it stopped at 108, then at 124, then failed to index R38 for a day); the ADR row count against §11 (wrong ten times in five days, across three copies in one file); every ADR-NNN cited anywhere in the REPOSITORY against the index — the reach that finds a 133, cited by seven merged source files with no row and found until now by hand arithmetic; the On code paragraph’s date against the newest decision record; a spike recorded closed and still named as a blocker; and a backticked repository path cited from a .props, .csproj, .targets or a .md outside docs/, which is doc_reference_lint.py’s structural blind spot. It runs in docs-lint.yml, NOT in the platform’s own workflow, and that OVERRIDES the brief: docs-platform-ci.yml is path-filtered for Actions budget and does not fire on a .cs change — which is exactly the change that produces a 133, so a gate placed there is the tee defect in a different costume. ADR-112 is not weakened, because it governs where the LOGIC lives and the logic is a portable module under tools/. A ratchet with a two-sided ledger, a reason per row and a parse-time refusal without one: an undeclared finding fails, and a declared line that no longer fires also fails, so the ledger can only shrink. First run: 44 findings, 17 fixed in the same change, 13 declared — eight deliberate absences, two live defects another brief owns, and the 093/133 pair. Clause 6 widens ADR-114’s smoke assertion from one URL to seven: Pagefind builds a static index at build time over what the build rendered, so an index is content, and a boundary proved on a page and not on /pagefind/ is proved on a neighbour; a 404 and a network error are failures rather than passes. Not done, and it is the larger half — the site is still not deployed, so that assertion has never run: the Pages project and the Access policy need credentials this repository does not hold, and they are RC-D-01 on the release checklist R44 had to create because ADR-045’s fourth enforcement option had no artefact behind it (F-R44-02). The mutation sweep is 25 of 25, and its one survivor was an UNREACHABLE guard in the checker rather than a hole in the suite. Clause 5 is written as description rather than as a requirement, because it can name no enforcement point: the mutation suite runs in a different workflow from the gate | Accepted | Amends 114 (clause 5’s smoke assertion: one corpus page becomes seven URLs, and the search index comes inside the boundary it asserts); Depends-on 112 (the plugin-portability and exit-cost rule this obeys, and whose placement clause it argues against on reach); Depends-on 045 (the enforcement-point criterion every clause is written against, and whose fourth option F-R44-02 found unbacked); interacts with 113, 115, 106, 162, 174; introduces no Q-ID | Reading the table: of the 31 original decisions, 5 were retired (018, 019, 021, 024, 028), 16 were amended, and 10 stand unchanged. The retired five are the instructive ones: in four of the five the problem statement was right and only the mechanism failed, which is why each superseding record carries the original reasoning forward explicitly — ADR-019’s argument about re-inserting events below the projection high-water mark, and ADR-024’s arithmetic on backplane fan-out, are both still the reason their replacements are shaped the way they are. Retiring a decision does not retire its diagnosis.

12. Creator Ecosystem SDK (TypeScript API)

To empower the community without compromising the VTT’s Zero-Trust Architecture, the frontend exposes a strict, namespace-driven SDK (@descent-vtt/sdk) for modders and creators. The SDK operates as an RPC client bridge, strictly isolating third-party code within the QuickJS WebAssembly engine, far away from the underlying WebGPU engine and raw DOM.

12.1 SDK Architecture & Developer Experience

  • NPM Distribution & Vite Sandbox: The SDK is distributed as an NPM package, providing perfect TypeScript autocomplete. The included descent-vtt-cli spins up a local Vite development server that simulates the WASM worker environment and renders JSON AST outputs for Hot Module Replacement (HMR) testing.
  • Declarative UI Toolkit: Modders do not write raw HTML/CSS or use frameworks like React/Vue. The SDK provides a rich, declarative UI component library (e.g., ui.VStack, ui.Button). Creators construct UI logic natively in TS/JS, which the SDK compiles into JSON ASTs for the SolidJS host to render.

12.2 The 6 Core Namespaces

  1. descent.state (Optimistic State Sync): Modders register reactive data stores against a schema, and the SDK names no validation library — it accepts any Standard Schema-conformant validator, so Zod, Valibot, ArkType and TypeBox are all admissible and so is anything that conforms later (ADR-099). Valibot is the documented default in the templates and examples, and is explicitly not a requirement; the SDK bundles no validator, so a creator pays only for the one they choose. This sentence previously read “Modders must define Zod schemas”, which put a named third-party library inside a published contract that every creator inherits — the defect is the naming rather than the name, and it is why the replacement is an interface. What makes accepting any library safe is that creator-side validation is advisory and is re-validated server-side on the terms ADR-098 already sets for Studio; the platform never treats a creator’s schema as a trust input. The SDK handles IndexedDB offline Command Queue buffering and Server Reconciliation. Reconciliation is explicit, not transparent: every mutation returns { tentative, confirmed: Promise }, and rejection invokes a mandatory onRejected handler. The SDK will not silently roll back a store whose value the creator’s subsequent code has already consumed — a rollback that the creator cannot observe produces logic built on a premise that no longer exists (e.g. damage resolved from a position the server refused). Queued commands carry their causal premise; when a command is rejected, every dependent command is invalidated and reported as a set rather than individually reversed.
  2. descent.ui (Window Manager Shell & Render Routing): Prohibits direct DOM manipulation. Modders use API methods (createWindow()) and populate UI by returning JSON AST UI elements. The Main Thread acts as an intelligent Render Router: If the JSON targets the Screen (e.g., a Combat Tracker), SolidJS renders it as HTML DOM. If the JSON targets a Token (e.g., an HP bar), it is routed to the WebGPU Dynamic Atlas and rendered via hardware instancing. SolidJS natively supports multi-monitor pop-outs by broadcasting these JSON descriptors to slave windows. Target capability is validated at registration time, not silently degraded at runtime: the atlas target renders bitmap glyphs via hardware instancing and cannot host focus, IME text entry, scrolling, or hit-tested sub-regions, so a descriptor placing ui.TextField or a scroll container on a Token target is rejected with a compile/registration error naming the offending node. The router never quietly drops interactive affordances a creator asked for.
  3. descent.scene (ECS Abstraction & Compute Router): Completely hides the Babylon.js instance and the SAB channels. Modders issue semantic ECS commands (e.g., updateToken, spawnVFX, calculatePath). The SDK routes requests to the appropriate backend (own-move pathing to the Descent.Geometry worker, visual upsampling to the WebGPU compute pipeline), abstracting heterogeneous computing from the creator.
    • Predictive results are typed as predictions, so calculatePath returns { predicted, authoritative: Promise } rather than a bare value, and a creator resolving per-step consequences (traps, opportunity attacks) must await authoritative. The type system makes the distinction unavoidable instead of leaving it to be discovered in production.
    • Vision queries return authoritative-only — there is no predicted member to misuse (ADR-034). A local vision result depends on the occluder set, and the client is deliberately not given undisclosed occluders (§9.2), so a predicted visibility value would be systematically wrong wherever hidden geometry exists and its divergence from authoritative would be a disclosure channel. Removing the member is stronger than documenting the hazard: a creator cannot await something that is never offered. For the same reason calculatePath returns predicted only within the caller’s disclosed region and otherwise resolves authoritative-only — otherwise a charge preview routes through an unrevealed door and the creator’s opportunity-attack logic fires on the wrong cells.
  4. descent.dice (Physics & Cryptography): Triggers server-validated 3D dice rolls. Returns a Promise that resolves once the authoritative result is available; the settling animation is presentation and never gates resolution. Replay Mode is a first-class SDK mode: in the network-detached client replay (§7.3.2) every server-dependent namespace resolves from the recorded stream or rejects with E_REPLAY_UNAVAILABLE. No SDK Promise may hang indefinitely because the network is absent by design — a cartridge that awaits a roll during replay must fail loudly at that line, not stall the timeline.
  5. descent.events (Event Bus): Provides subscription hooks to game state changes (e.g., combat:turnStart, chat:message). The word “global” was removed by ADR-116, and the removal is the decision rather than an edit for tone. ADR-086 bounds a plugin’s interest set by the disclosure set of the principal who admitted it, and §9.5 Guardrail 5 delivers the per-frame snapshot under that bound — but the event bus is a second subscription surface and ADR-086 reached only the first. A plugin admitted by a player could otherwise be handed an event naming an entity that player cannot see, which is a disclosure channel wearing the costume of a convenience API. Therefore: every event is filtered through §8.2’s Visibility Channels against the admitting principal at the tick it is raised; an event naming an undisclosed entity is dropped, never redacted — a redacted event still discloses that something happened — and no “you missed an event” signal exists, because absence must stay uninformative on the reasoning ADR-101 records for the announcement stream. descent.dice results obey ADR-094 unchanged: a plugin sees the net outcome its principal sees, never an opposition’s rating, threshold or unresolved roll.
  6. descent.rules (Licensed Plugin Bridge): For proprietary rule systems (e.g., Call of Cthulhu 7E Sanity/Pushed Rolls), the logic cannot exist on the client due to IP DRM and Anti-Cheat mandates. This namespace acts as an RPC bridge. It automatically verifies the Game Master’s license for the module and executes proprietary calculations via dynamically loaded .NET Plugins (DLLs) on the backend, returning only the final display result to the frontend. Those plugins are Tier P0 privileged code (§6.4): they run in-process with full host privileges and are therefore restricted to signed, code-reviewed first-party/partner cartridges. This namespace is not, and must not be described as, a sandbox for community rule logic — community logic uses Tier P1/P2 (§4.3, §9.5 Guardrail 5).
    • The RulesetReadOnly state has three causes and one behaviour. A licence that lapses mid-session, a cartridge that cannot be loaded on the current SDK major (§3.1, ADR-055), and an event stream whose types cannot be resolved (§3.1 Archive Mode) all converge on the same state: history, replay and export work; new commands for that ruleset are refused with an explicit reason; the room opens. Reusing one state rather than inventing three is deliberate — each of the three would otherwise arrive with its own half-built UI, and two of them would arrive as a failed activation, which is how a paid campaign becomes unopenable.
    • Verifiability has a boundary here too. Because the evaluation function stays server-side, a player cannot recompute a licensed roll’s meaning; §4.1 therefore scopes the fairness guarantee to a publicly verifiable byte stream plus an auditable record of the inputs, thresholds and outputs used. Keeping the code private and the numbers public is the only combination that satisfies both this namespace’s premise and §4.1’s claim.

13. Development Roadmap

🧱 Phase 1: Core Engine & World Model Forging

  • Upgrade Descent.RngKit with Superpower AST parser and robust AST defenses (ADR-001).
  • Implement Descent.Sandbox prewarming pool and fuzzing safety suite.
  • Build Descent.Vtt.Domain with strongly-typed IDs (RoomId, ActorId) and spatial 3D world models.
  • Establish Descent.Vtt.Sdk version v1.0.0 semantic boundaries.

⚙️ Phase 2: Backend Authority, Simulation Layer & Infra

  • Integrate PostgreSQL, PostGIS (Npgsql.NetTopologySuite), and Marten.
  • Implement FusionCache + MemoryPack caching and the IAttributeDefinition Schema Registry.
  • Construct the dynamic AssemblyLoadContext loader for game cartridges (e.g., CoC7e).
  • Build the World Simulation Engine (fixed 20Hz tick loop driven outside the Grain mailbox, BVH spatial partitioning, Triggers, Command Validation) on top of the Descent.Geometry Rust crate hosted natively (ADR-017), with the geometry_parity.json golden corpus wired into CI from day one.
  • Land the following before the first read model is written — each changes every stream, projection, and snapshot key, and none can be retrofitted cheaply: the State Authority Tiers and Ephemeral Ownership Lease (ADR-016, ADR-050), Scope-typed query keys (ADR-042) — retrofitting these means rewriting every repository interface — event partitioning on an immutable key (ADR-036), branch-keyed explored FOW chunks (ADR-035), durability ordering for snapshots (ADR-037), and branch keying generally (ADR-020).
  • Stand up the parity corpus with its high-precision oracle from the outset (ADR-056). Adding a correctness reference after a corpus exists means adding it to results that have already been baselined as correct — and the specific failure it detects (identical wrapping arithmetic producing identically wrong answers on both hosts) is invisible to any native-vs-WASM comparison.

🌐 Phase 3: Networking Matrix, State Replication & Frontend Skeleton

  • High-Frequency Hot Path & Dual Communication Routing: Implement SignalR + WebSockets + MessagePack/FlatBuffers binary channels with room-affine ingress (ADR-024); integrate LiveKit SFU + Web Audio API for scalable low-latency voice and real-time monster voice-changer filters. Ship the Yjs SignalR relay fallback (ADR-026) in the same phase as the SFU path — never after it, or the first restricted-network user loses authored content.
  • World State Replication & Offline Resilience: Implement World State Replication (per-viewer Full Snapshot, Delta Sync with baseline versioning, AOI-entry baselines, and Visibility Channel filtering — ADR-023).
  • Offline-First Hydration (scoped — ADR-051): Establish an offline/online dual-track synchronization mechanism based on PWA and IndexedDB (Command Queue), using CQRS Optimistic UI and Server Reconciliation handshakes to guarantee authoritative state integrity without bypassing Orleans. “Offline-first” covers authored content and sheet-level state, not world interaction: spatial actions require a lease that cannot be acquired offline, so they become a local planning layer presented for execution on reconnect. The honest phrasing is “offline you can prepare, record and create; playing requires a connection” — correct for a server-authoritative real-time VTT, and narrower than the term implies.
  • Client Update Contract (ADR-054) ships with the transport, not after it. Build-id-keyed caches, a never-Cache-First app shell, and the pre-handshake minimum-version endpoint must exist before the first breaking protocol change, or that change locks out every returning player and the only escape destroys their offline data. §5.1.1 alone adds four protocol surfaces, so the first breaking change is a matter of when.
  • Pull the WASM geometry build and its parity gate forward from Phase 4. Client-side prediction (§9.6.4) belongs to this phase, and without the crate compiled to WASM an implementer writes a stand-in — which is the second geometry implementation ADR-017 exists to forbid, and it would become the baseline every netcode measurement is tuned against. ADR-052 makes this cheaper than it was: one artefact, no +atomics, no shared-memory toolchain.
  • Modernized Frontend & Security Authentication: Implement the SolidJS reactive framework (0-VDOM fine-grained signals), a nested Solid reactive store architecture, a retro dark-themed component library using Tailwind CSS, and passwordless biometric security login via FIDO2 / Passkeys.
  • Capability resolution goes through the veto seam from its first use (ADR-064). The moment any code asks “is WebGPU available”, the answer must come from the single injection point of §14.6, because retrofitting one later means finding every capability check ever written — and until it exists, every Guardrail 7 row is asserted by prose alone.
  • Backend Stewardship Services: Deploy persistent background workers (e.g., RoomLifecycleWorker for room hibernation and memory cleanup) and integrate AWS SES for email dispatch.

🎨 Phase 4: 3D World Engine, Asset Pipeline & Rendering Profiles

  • Worker Topology & Main Thread Decoupling: Strictly enforce OffscreenCanvas + Web Worker to move all graphics rendering off the main thread, and establish the five-tier topology with the Network Worker’s dual fan-out (ADR-053) — the DOM-facing channel is what makes Guardrail 1’s Recovering state possible, so it is not an optimisation to add later. SAB is used for exactly two Render-Worker-to-Main-Thread channels (§9.1.2, §9.1.3); the shared WASM arena is not built (ADR-052), which removes the +atomics toolchain, the second WASM artefact, and any hard dependency on cross-origin isolation from this phase’s scope.
  • All draw submissions pass through one recording seam (ADR-063). The render digest of §14.3 is the only verification the render path gets that is neither flaky nor blind, and it is unimplementable over a renderer that issues draws from arbitrary call sites. Building the seam with the renderer is cheap; adding it afterwards means editing every call site in the least safe part of the codebase.
  • Frontend ECS & Rendering Engine: Implement Babylon.js v8+ (Profile A/B) and PixiJS v8 (Profile C fallback) backed by a lightweight Frontend ECS (BitECS/Kajiya) to align contiguous memory buffers with Thin Instances.
  • WASM Compute Slicing (scope narrowed — ADR-034, ADR-052): Compile the same Descent.Geometry crate revision from Phase 2 to WebAssembly — one artefact, private linear memory, single worker — for own-entity movement prediction, collision and A* preview within the caller’s disclosed region, and rule pre-validation, gated by the parity corpus. FOW and LOS are not client-side computations: they are server-owned, and both the WASM path and the WGSL path present the authoritative mask (ADR-017, ADR-034). Prediction of visibility was withdrawn because bit-exactness holds only over identical inputs, and the client is deliberately never given the full occluder set — so the correction would be a repeatable disclosure channel rather than a convergence.
  • Sanity-Linked Shaders & Dual-Target Compilation: Implement rendering pipelines with Dual-Target Shader Cross-Compilation, supporting real-time screen distortion.
  • WebRTC Spatial Audio & Mesh Texture Linkage: Render WebRTC video as textures on 3D Token Meshes, and apply distance attenuation and Sanity-Linked Audio Distortion via Web Audio API — attenuation over the viewer’s disclosed set only (ADR-167 clause 3), and not on the Discord Activity surface (ADR-167 clause 1).
  • UGC Creation & Zero-Trust Declarative UI: Establish the Asset Bundle System and pre-baking pipeline (KTX2/Basis Universal). Implement the first-party JSON AST renderer (Lit / Web Components used as an internal rendering host for style encapsulation only — never as a delivery format for third-party code, per ADR-010 and §6.4), alongside a SolidJS + Rete.js visual node editor and Monaco Editor for sandboxed macros, to build a 100% zero-trust scenario workshop.

🤖 Phase 5: AI Assistance & Infrastructure Evolution

  • Implement the Knowledge & AI Layer (ILLMProvider + pgvector) for RAG rule references, including corpus provenance keys and the licence-revocation purge path (§7.2).
  • Evaluate WebTransport (HTTP/3) migration strictly as a spike against the three blockers in ADR-029 (SignalR transport support, end-to-end HTTP/3 through Cloudflare + ACA ingress, and ephemeral-path duplication). No migration without a measured latency benefit.
  • Finalize production ACA Scale-to-Zero deployment with the Google Cloud Run reduced-capacity DR tier (ADR-030) and OpenTelemetry monitoring. (AWS ECS Fargate is explicitly out of scope — §10.1 rejects it for lacking native HTTP scale-to-zero; the earlier mention of it here contradicted that decision.)

🛡️ Cross-Cutting Workstreams (not a phase — these span all phases)

Several of this document’s hardest subsystems were absent from the phased plan, which is how they came to be under-specified. They are tracked explicitly:

  • Data Lifecycle: event-stream partitioning and cold detach/attach (ADR-019), branch keying (ADR-020), and the declarative upcaster registry — all landing before the first read model (Phase 2).
  • Cartridge Evolution (§7.5): the attribute-registry retired state, the two-release deprecation, campaign-level cartridge pinning, the publish-time reverse-dependency index, and the state migration artefact that timeline checkout depends on (ADR-069/070/071). The event-shape half of this is a Phase 2 prerequisite; the rest becomes load-bearing the first time a third-party cartridge ships a major, which is well before Phase 5.
  • Profile C Interaction (§9.7): the C-Tactical / C-Companion split, the lease-release-on-background rule (ADR-067) — which is server-side behaviour, not styling — and the UI-descriptor role/priority annotations that make small-screen layout the renderer’s job rather than each cartridge author’s (ADR-068).
  • Branching & Time Travel Engine (§7.3): fork semantics, branch-keyed projections/embeddings, branch GC.
  • Content Protection Pipeline (§6.1): per-version key/nonce derivation, chunked streaming, and the honest threat-model documentation.
  • Creator Ecosystem SDK (§12): tentative/confirmed API surface, registration-time UI target validation, Replay Mode, and the trust-tier boundaries of §6.4.
  • EDoS & Abuse Defence (§10.3): quota enforcement at URL issuance, adaptive ephemeral rates, aggregate relay budgets.
  • Day-2 Operability (§10.4, ADR-031): diagnostic envelope, per-viewer replication digest, operator time-machine, and per-class rollback paths — delivered with the features they diagnose, not after the first incident.
  • Capability Matrix (Guardrail 7, ADR-027): maintained as a release gate from the first frontend milestone onward.
  • Verification & QA (§14): the render-submission recording seam (§14.3) and the capability-veto injection point (§14.6) are architectural seams that must exist before the code they observe, not test infrastructure to be added later.

14. Verification & Quality Strategy

Until now the test suite in this document was a directory tree (§3) and a scattering of CI assertions attached to individual decisions. That is not a strategy, and the gap has a specific shape: the architecture makes strong claims — bit-exact geometry parity, no tearing across shared memory, defined behaviour in every Guardrail 7 cell, an ordered degradation ladder under memory pressure, a lease handback that preserves visual continuity — and a claim with no executable check is a claim that will be false within two release cycles with nobody noticing. This chapter says which claim is checked by what, and, equally important, which claims are checked by nothing and are therefore the job of humans.

14.1 The Verification Ladder — Determinism First

The system is stratified by how much determinism it actually has, and assertion strength is placed accordingly.

TierSubjectAssertionWhere it runs
T1 — ExactDomain and rules, projections and upcasters, the Descent.Geometry crate, protocol encoders, the SAB ring protocols, ECS state derived from a snapshot streamValue equality / bit equalityEvery PR. No GPU, no browser
T2 — StructuralThe render path: what would be drawnDigest of the draw-submission stream (§14.3)Every PR, headless, software adapter
T3 — PerceptualShader and material outputTolerance comparison against a pinned software adapterOnly on changes to shader/material code
T4 — HardwareProfile resolution, real drivers, wall-clock budgetsGuardrail 7 capability probes + performance budgetsNightly, device lab. Never a per-PR gate
T5 — SessionWhole-system netcode and rules behaviour over timeRecorded event logs replayed to identical digests (§14.5)Nightly and pre-release

The rule that orders the ladder: an assertion belongs at the highest tier whose determinism it can genuinely rely on, and no higher. The failure this prevents is the ordinary one — a team writes an end-to-end screenshot test for what is actually a pure function, then spends a year debugging the harness instead of the rule.

The corollary is the part that is easy to get wrong: most of what looks like it needs a GPU does not. Visibility is server-owned (§5.4, ADR-017, ADR-034), so what a player is permitted to see is decided, and therefore asserted, entirely in T1. Fog-of-War correctness is not a rendering test. If a test’s failure mode is “the wrong cells were revealed”, writing it as an image comparison is a category error — it converts an exact, cheap, deterministic assertion into an expensive, flaky, approximate one, and it puts the platform’s central security property behind the least reliable tier in the ladder.

14.2 Pixel-Diff Visual Regression Is Not a Correctness Oracle (ADR-063)

Playwright driving the client with a pixel comparator over the canvas is the obvious proposal, and it is the one a reader of this document will re-propose every year. It is rejected as a gate for three independent reasons, each sufficient on its own:

  1. GPU rasterisation is not reproducible across vendors, drivers, or driver revisions. Precision of intermediate arithmetic, fp16 availability, rasterisation coverage rounding, texture filtering and mip selection all differ legitimately between NVIDIA, AMD, Apple and Intel — and between two driver releases from the same vendor. A pixel baseline is therefore either recorded per (vendor × driver × OS × browser) — a matrix that decays faster than it can be maintained — or thresholded loosely enough that it no longer detects the regressions it exists for. There is no stable middle setting; that is a property of the hardware, not of the tooling.
  2. CI has no GPU. The realistic hosted runner resolves to a software adapter (Dawn/SwiftShader, lavapipe). A green pixel test there demonstrates that the software backend renders. It says nothing whatsoever about the Profile A path a player runs. A false control is worse than no control, because it removes the pressure to build the real one — and this document’s own history (§9.5 Guardrail 3, §6.1) shows what happens when a justification stops holding but the control stays.
  3. The declared parity target is semantic, not visual (ADR-061). Profile C draws baked top-down tiles (ADR-005); there is no 3D scene to compare against Profile A’s. A visual-regression suite generalised across profiles asserts a property this document has explicitly rejected, and it would fail correct builds.

What the proposal is right about, and where it therefore belongs. A large part of this client is ordinary, deterministic DOM: SolidJS panels, character sheets, the radial menu, turn order, the JSON-AST renderer’s output (§9.5 Guardrail 5), the four distinct wake states (Guardrail 6), and every “defined user-visible behaviour” string in the Guardrail 7 matrix. Those are cheap and stable to snapshot, and regressions there are exactly the ones humans stop noticing. Playwright is therefore adopted — as a DOM and interaction harness, with canvas pixel comparison excluded by default and permitted only under the T3 conditions below. The distinction is not pedantry: it is the difference between a suite the team trusts and a suite the team learns to re-run until it passes.

T3, scoped honestly. A small, curated set of golden images, rendered on a pinned software adapter at a pinned revision, compared perceptually rather than per-pixel, run only when shader or material code changes. Its declared role is change detector, not correctness oracle: it answers “did this shader edit alter output it was not supposed to alter”, and it is never evidence that the effect is correct on real hardware. That evidence comes from T4 and from human review, and saying so here is what stops the golden set from being cited later as though it were a parity guarantee.

14.3 Verifying the Render Path Without Comparing Pixels: the Render Digest

The mechanism that replaces pixel comparison at T2 is the direct analogue of ADR-057’s applied-state digest. In test and debug builds the Render Worker emits a canonical, ordered draw-submission digest for a frame: per pass, the pipeline identity, bind-group layout identities, instance count, quantised instance transforms in a stable order, the texture-atlas dirty-rect list, the mask tick being presented (§5.2), and the LOD tier selected per asset. It is fully deterministic, vendor-independent, textually diffable, and it costs no GPU.

What it catches — and note that a screenshot catches some of these only by luck and others not at all:

  • A token that is not submitted at all, or submitted to the wrong pass or layer.
  • A whole-atlas re-upload where §9.1.1 budgets a dirty rect. This is an upload behaviour claim; a screenshot of the resulting frame is identical either way, so the pixel test is structurally blind to the exact regression the budget exists to prevent.
  • Instance counts that grow monotonically across frames — the leak signature — which no single screenshot can express.
  • The fog mask failing to advance, or advancing at frame rate rather than at the Focus-tier-plus-one-tick cadence §9.2 requires.
  • Video-texture re-import per frame (§9.4 constraint 2) degrading into a cached bind group, or the concurrent decode cap being exceeded.

What it does not catch, stated so nobody relies on it for this: whether a shader produces the intended colour. That is T3’s job, and T3 is a change detector.

The engine constraint this imposes, which is why it appears in the architecture and not in a test README. The digest requires a single recording seam through which every draw submission passes. If draws are issued ad hoc from many call sites — the natural way to write a renderer — the digest is unimplementable, and retrofitting the seam means touching every one of those call sites at exactly the point in the project where the renderer is least safe to disturb. The seam is a Phase 4 design constraint (§13).

14.4 SharedArrayBuffer, Worker Topology, and the Boundaries Nothing Currently Guards

Races are not found by running the program. The SPSC presentation ring (§9.1.2) and the triple-buffer pointer swap (§9.1.3) are the platform’s two shared-memory protocols, and neither is testable by example: a passing run proves only that one interleaving out of an unbounded set happened to be benign, and the interleaving that matters is the one that occurs on a player’s machine under load. Four checks replace the instinct to “run it a lot”:

  1. The protocol is extracted and exhaustively explored. Each protocol is written once as a small module with an explicit step function, and its interleavings are enumerated under a deterministic scheduler in test code (or specified in TLA+ if the state space justifies it). The invariants asserted are the ones §9.1 and ADR-025 actually promise: the reader never observes a partially written block; the consumer’s detected sequence gaps correspond exactly to real drops; the drop policy drops oldest and only oldest; and RingOverflow equals the number dropped, because a counter that under-reports turns a capacity problem into a mystery.
  2. A tear detector runs in every debug build, not only under test. Each published block carries a leading and a trailing sequence word and the reader asserts they match after the copy. This catches during a playtest what no CI interleaving search will reach, and it costs two words per frame. It is the highest-yield item in this chapter per line of code.
  3. A real-thread stress tier — real Workers, real SAB, producer driven at deliberately hostile rates and with deliberately adversarial pause patterns. It is a falsifier, never a prover: it is allowed to fail the build, and it is never counted as coverage of the protocol, because the argument “it passed 10,000 runs” is precisely the reasoning that ships tearing bugs.
  4. The degraded transport is a first-class target, not a fallback. Without cross-origin isolation both channels become postMessage batched per frame (Guardrail 7). Every developer machine will be isolated, so this is the path that rots, and it rots invisibly because nothing on it is a capability — ADR-052 removed the last one. Both transports therefore run the same suite, toggled by §14.6’s veto.

The frontend has no architecture tests, and its boundaries are now the load-bearing ones. Descent.ArchitectureTests (§3) guards the .NET dependency boundaries; the frontend’s equivalent boundaries are guarded by review alone, and review does not hold for a decade. Under ADR-053 the Network Worker is the single arrival authority — a statement about an import graph, which a static check enforces permanently and a reviewer enforces until the week they are on leave. The following assertions fail the build (dependency-cruiser or equivalent):

  • Only the Network Worker module may import the transport client. This is ADR-053 stated in a form a machine can check.
  • No renderer module may import the command or mutation surface (ADR-089). §5.1’s “Renderers MUST NOT mutate Domain World State directly” is the oldest structural claim in this document and was, until 2026-08-02, guarded by nothing at all — the enforcement-point lint found it with no named mechanism anywhere. Mutations reach the server only through the CQRS Command Queue, which is ADR-007’s Intent Command pattern expressed as an import-graph property.
  • No module outside the two designated publisher modules may write to the SAB views (§9.1.2, §9.1.3). “Both sides participate in the protocol” is only true if both sides are the only sides.
  • The geometry worker’s module graph may not reach the render or DOM modules. ADR-052’s private linear memory is a module-graph property; written as a comment it is an aspiration.
  • No plugin-facing SDK module may import the raw ECS or the GPUDevice (§12, Guardrail 5). The zero-trust boundary is an import boundary before it is a runtime one.
  • Nothing in the game-client route’s import graph may resolve to an external origin. This check already exists (§6.1); it is listed here so the full set of frontend build-failing boundaries is enumerated in one place rather than discovered one incident at a time.

14.5 The Event Log Is the Regression Corpus (ADR-065)

Because the system is event-sourced, its strongest test asset already exists and costs nothing to collect. Recorded sessions — internal playtests, plus opt-in exports from production incidents (§10.4) — are replayed in CI on both sides of the boundary:

  • Server side (T1, exact): the rules and projection layer replays the stream and must produce identical projected state. This is simultaneously the only real test of every upcaster (§7.1, §7.5): an upcaster’s correctness is a claim about events written years ago by cartridges that may no longer load, and it cannot be demonstrated by a unit test written against today’s event shape.
  • Client side (T2/T5): the client replays the same stream headlessly and must produce an identical applied-state digest (ADR-057). The digest already exists for operator diagnosis; reusing it as the CI oracle costs nothing extra and gives a property worth having deliberately — the production diagnostic and the test oracle cannot drift apart, because they are the same artefact. A diagnostic that is exercised only during incidents is a diagnostic that is broken during incidents.

Corpus governance, because every golden corpus decays into a rubber stamp. This corpus and geometry_parity.json (§5.3, ADR-056) share one failure mode: a change breaks an expectation, and the cheapest green is to edit the expectation. Three rules:

  • Adding a case is unreviewed and actively encouraged. Friction belongs on changes, not on growth.
  • Changing or deleting an expected value is a separate commit, reviewed by someone other than the author, with a written justification — and where it encodes a decision, an ADR. A golden file whose expectations may be edited in the same commit as the code that broke them asserts nothing at all; it records the most recent behaviour and calls it correct.
  • The independent oracle (ADR-056) may never be regenerated from the implementation. This is stated explicitly because it is the exact shortcut that gets taken under deadline, it looks like maintenance, and it silently converts the one independent correctness reference in the system into a tautology. The failure ADR-056 exists to detect — identical wrapping arithmetic producing identically wrong answers on both hosts — becomes undetectable again the moment it happens.

14.6 Testing the Conditions That Never Occur in CI (ADR-064)

This document’s most consequential claims are claims about scarcity and failure: every row of the Guardrail 7 matrix, ADR-048’s ordered degradation ladder, Guardrail 1’s device loss and terminal fallback to Profile B then C, Guardrail 6’s four distinct wake states, ADR-050’s lease handback and staleAnchor, ADR-055’s expiry into ruleset-read-only, ADR-039’s quarantined decode, ADR-051’s disabled-with-a-reason offline surfaces. None of these conditions arise on their own in a test environment, and a matrix cell whose behaviour has never once executed is prose. The Guardrail 7 rule “a blank cell blocks release” was already a real improvement; it is still satisfiable by writing a sentence.

A test-only capability veto is therefore a first-class, built-in component rather than a mocking technique:

  • A single injection point resolves every capability — WebGPU, cross-origin isolation/SAB, OPFS, WebCodecs and hardware decode sessions, WebRTC/SFU reachability, IndexedDB and persistent-storage quota, worker-scope font rasterisation — and any of them can be forced to absent or degraded, per automated test and per manual QA session.
  • Resource budgets are settable to arbitrarily small values: the §9.6.2 VRAM residency budget, ADR-048’s single visibility-memory budget, the §9.5 plugin budgets. A degradation ladder whose first rung is reached after four hours of play is a ladder that is tested once, by a customer.
  • Faults are injectable on both sides: device.lost, silo failover mid-tick, projection lag behind the rebuild watermark (ADR-043), lease expiry and forced handback, a 503 cold-start wake, a refused protocolVersion handshake (ADR-054), an SFU that accepts the connection and then silently drops.
  • Guardrail 7 becomes executable. Every row names the test that demonstrates its stated absent-capability behaviour, and the release gate strengthens from “every cell has text” to “every cell has a passing test”. A cell whose test does not exist is treated exactly as a blank cell — which is the change that makes ADR-027 mean what it always claimed to mean.

Two consequences are accepted deliberately. First, the veto is compiled out of production builds, and this is not optional: every path it reaches is a downgrade, so a runtime capability override surviving into production would hand an attacker a supported mechanism for disabling controls. Second, the injection point is a permanent architectural seam that every capability check in the client must route through — a real constraint on how the code is written, which is why it belongs in the architecture and appears in §13’s cross-cutting workstreams rather than in a testing appendix.

14.7 Security, Abuse and Anti-Cheat Verification

  • Fuzzing already exists structurally (§3, §3.1): SharpFuzz over the Jint sandbox, cargo-fuzz over degenerate occluders and unreachable goals. Two rules are repeated here because they are the ones that get quietly dropped: fuzz against the production threshold set in private CI — a campaign against the open-repository defaults exercises a configuration no deployment runs, and reports coverage of the wrong system — and fail the build on a security-tagged advisory in either submodule.

  • Disclosure invariants are schema tests, not review items (ADR-038). An ephemeral payload may contain only sender-input-derived values and transforms of leased entities. Enforce that over the payload schema registry: every field of every ephemeral message declares its provenance, and a field whose provenance resolves to a visibility-set or occluder-derived computation fails the build. Review caught this once — the vision cone in §9.6.3 — and the recurrence will be silent, because a disclosure channel produces no error and no symptom. This is the class of bug where “we reviewed it” and “it is enforced” are separated by years.

  • Per-viewer filtering is a property test, generated rather than enumerated. For a randomly generated room state and a random viewer, no per-viewer snapshot (§8.2), no export archive (ADR-022, ADR-059), and no accessibility semantic mirror (Guardrail 7) may contain an entity outside that viewer’s disclosed set. Enumerated cases only cover the leaks already imagined; the accessibility mirror is in this list precisely because it is the newest surface and the one whose filter has never run.

    • Containment is a property of a structure, and the accessibility path also emits a stream (ADR-101). A mirror can be sampled and inspected; an announcement sequence cannot — its disclosure carriers are rate, ordering and silence, none of which a containment assertion over a DOM tree can see. This is the same split §14.7 already makes on the visual path, where revealed-set containment and correction timing carries no information are two separate properties because the first cannot detect the second. The stream property: for a generated sequence of mask updates, the emitted announcement schedule must be identical under permutation of those updates’ contents. A snapshot assertion cannot express that, which is why it is written as its own property rather than folded into the one above.
  • Presentation may not widen the mask, and this is three generated assertions rather than one (ADR-034 clause 1). §9.2 permits the presentation layer to smooth, upsample, antialias and animate the authoritative mask, and every one of those verbs is a filter whose output could reveal a cell the server did not. The permission and the prohibition sit one sentence apart, so they are enforced as properties over generated inputs, in the same form as per-viewer filtering above:

    • Revealed-set containment. For a generated authoritative mask and any presentation filter over it — distance-transform feather, morphological pass, or an ML upsampler — the set of cells with non-zero visibility in the output must be a subset of the set revealed by the mask. Feathering therefore works inward only, and the visible price is a fog edge marginally more conservative than the authoritative one. That price is the fail-safe direction and is stated here because the obvious way to “fix” the shrinkage is to feather outward by half a cell, which is precisely the defect. A generative upsampler is in scope for this assertion rather than exempt from it: a model trained on map data will continue a wall past the revealed boundary with better-than-chance accuracy, and it will render the guess with the same confidence as fact.
    • Illumination containment. No lit texel produced by client-side 2D GI may appear in a cell the authoritative mask marks unrevealed, with light sources generated adversarially near unrevealed boundaries. Light is not visibility licenses the feature, but light propagation is a connectivity oracle and a better one than line of sight — a glow bending around a corner reports that the corner is open. The invariant is what keeps the premise true rather than merely plausible.
    • Correction timing carries no information (clause 3). The mask crossfade’s duration and easing must be a pure function of time since mask arrival, and must not vary with the magnitude of the mask delta. Asserted against the declared shader parameter and the render digest, never against wall-clock frame time — per §14.8, a shared runner’s timing measures the runner. An adaptive fade is the failure: “how fast the fog moved” becomes distinguishable between something was there and nothing was there, which is the channel ADR-034 closed by scoping and would reopen through animation.

    Enforcement point: the frontend suite, which does not exist yet — recorded as a gap rather than as a plan. These are properties of shader and worker output, so they cannot run in the backend suites, and the three above are unenforceable until there is somewhere to run them. §14.8’s rule about provisional numbers applies unchanged to invariants: one with no named enforcement point decays into a comment, and a disclosure invariant that has decayed produces no error and no symptom. The first frontend test to be written is this harness, before the shaders it constrains — writing it afterwards means writing it against an implementation, which is how a test comes to assert what the code does rather than what the rule requires.

  • The applied-state digest is explicitly not an anti-cheat signal (§10.4), and its use as a test oracle in §14.5 does not change that. A client-reported value is evidence about the client’s own belief, never about the player’s honesty.

14.8 Performance Is a Test, Not a Dashboard

§5.2 budgets the tick, §4.4 states the 5ms rule with its explicit AdvanceTickAsync exemption, §9.6.2 budgets residency. Under ADR-045 these are quantities with enforcement points, so:

  • Backend: p99 per Grain method is tracked per PR against a recorded baseline and a regression beyond the declared tolerance fails the build — with the tick’s exemption honoured explicitly, since a rule the tick violates four- to nine-fold is not a rule.
  • Frontend: budgets are asserted against the render digest and counters (upload bytes per frame, instance counts, atlas dirty-rect area, decode sessions, ring overflow count), not against wall-clock frame time in CI. A shared runner’s frame time measures the runner; asserting on it produces a suite that fails on Tuesdays. Wall-clock belongs to T4’s device lab, where the number refers to something.
  • The wall-clock rule is not a frontend rule, and confining it to the frontend bullet cost a real failure (added 2026-08-06). The reasoning above — a shared runner’s elapsed time measures the runner — is a property of shared runners, not of browsers, and it applies unchanged to any suite that runs concurrently with others. No test in any language may gate its pass/fail on how long something took, unless elapsed time is the subject of the test and it runs where the number refers to something (T4). The instance that produced this clause: Descent.Sandbox’s PrewarmedPool_UnderBurst_BuildsNoFurtherEngines is a construction-count test whose success assertion depended on each execution finishing inside the sandbox’s wall-clock execution budget. It passed in isolation and failed in a full-solution run where nine assemblies competed, then passed again on unchanged code. Its own comment claimed “no timing” and its own test-host helper documents that “timing is far too noisy” — both were right, and the assertion still smuggled timing back in through the success check. That is the shape to look for: not a stopwatch in the test, but an assertion whose truth depends on one.
    • Enforcement is §14.9, and no static check is claimed. A test that fails only under parallel load is a flake by §14.9’s definition, so it is fixed or deleted rather than quarantined or retried. Nothing detects this class statically; a reviewer noticing it is the whole mechanism, which is why the failure mode is written out above rather than the rule merely stated.
    • Two repairs are legitimate and a third is not, and the difference is not a matter of degree. Removing the dependence — asserting on a counter, a state, or an error kind rather than on completion — is the strongest. Making the timing incidental — a budget so far above any plausible stall that no scheduling delay reaches it — is acceptable where the assertion is load-bearing for a different reason, as with an IsSuccess check that exists to stop a construction-count test passing vacuously. Widening a threshold until the failure becomes rare is not a repair at all; it converts a test that fails into a test that fails later, on someone else’s branch. The distinction to apply is not how large the new number is but whether the test’s verdict still moves with machine load.
    • The class is bigger than the instance, and counting it is part of the disposition. In Descent.Sandbox’s suite, 63 provider constructions across 41 files reached the sandbox, only 14 set an execution budget, and 94 assertions depend on a script completing. Two were observed failing. Repairing only the observed two would have left roughly forty-seven, and would have read in the ledger as though the class had been dealt with — which is why the population was recorded rather than the two test names. The repair was consequently structural (ADR-100): every site now inherits one budget, and raising that one constant removes the sensitivity from all of them.
    • The obstacle to raising it was a single test, and finding that required per-test timings rather than a scan. A grep for “asserts a timeout, sets no budget” was run at file granularity for a per-test property, excluded the real culprit because a different test in the same file set a budget, and confidently reported two candidates that were not it. An aggregate computed at the wrong granularity does not return a wrong number; it returns a confident one. When a measurement and an explanation disagree, instrument at the granularity of the thing being explained.
  • Every ⊙-marked provisional quantity in this document names the benchmark that will make it normative. A provisional number with no named benchmark stays provisional forever, and a number that stays provisional long enough gets cited as though it were measured.

14.9 Suite Governance

  • Flaky tests are not retried. They are fixed or deleted, inside a stated window. An automatic retry converts a real race — precisely the class §9.1’s shared-memory protocols and ADR-033’s pipelined tick are most exposed to — into a statistic nobody reads. This is the single most important rule in this chapter and the one most likely to be reversed under release pressure, which is why it is written as a rule and not as a preference.
  • Coverage is not a gate. The gates are: T1 green; every Guardrail 7 cell backed by a passing capability-veto test (§14.6); the parity corpus green against all four targets including the independent oracle; the session corpus replaying to identical server state and identical client digests; and no golden-expectation change without its own reviewed commit.
  • What this strategy does not cover, named so the suite is never mistaken for a completeness claim: visual quality, the feel of latency compensation and correction cadence (§9.2), GPU driver bugs on specific hardware, whether a Keeper can genuinely run a session on a 6-inch screen (§9.7), and whether a degradation that behaves correctly is acceptable to the player it happens to. Those remain the job of the device lab and of human playtest, and no amount of tiering removes them.