Architecture Decision Rulings — R39
Architecture Decision Rulings — R39
Date: 2026-08-11 Scope: the retirement of the C# cartridge system, and Wasmtime as the only thing that runs a ruleset.
Three decisions: ADR-162 (Descent.RngKit and the ALC cartridge loader are deleted without
archival — except the DRBG, which was never RngKit’s to delete), ADR-163 (Wasmtime is the
exclusive cartridge host, with an on-disk AOT compilation cache and a two-engine split that turns
one axis and refuses the other), and ADR-164 (cartridge execution leaves the mailbox by
dispatch-and-reenter, closing the debt ADR-163 recorded against itself).
ADR-164 was added after the first two merged, which is why this document has a correction section between them: R39 shipped a cartridge host nothing could call, said so plainly in its own “what this work owes”, and then paid it.
The brief for this round was an instruction rather than a question, and two of its clauses were overridden against rules this repository already carried. That is recorded here rather than in a commit message, because both overrides are the kind that look like insubordination until the reason is written down.
| Question | Outcome | What decided it |
|---|---|---|
Delete Descent.RngKit entirely? | No — the DRBG is carved out and kept | It is the authority’s generator and a published wire contract, not a ruleset-authoring API. ADR-141’s precondition never reached it |
| Was ADR-141’s retirement precondition met? | Yes, for what it actually covered | It said RngKit retires “when the C# cartridges themselves become WebAssembly components”. They have |
| Does the exemption for a second DRBG end? | No, and the count does not return to one | Clause 5 said it ends “when one implementation ends”. Neither did |
| Grant the trusted engine extended WASI? | Refused | ADR-137 clause 3 makes zero imports the first invariant, and DescribeShapeFailure enforces it structurally |
| Two engines, then, differing in what? | Fuel, and nothing else | A trusted tier buys the removal of instruction counting. It does not buy a capability |
| Who decides a cartridge is official? | The host, never the manifest | A publisher signs their own manifest, so a self-declared tier is a self-granted privilege |
Write C# to evict stale .cwasm files? | Refused | A cached module is mmapped by every store using it. Wasmtime’s own LRU worker is inside the runtime holding those mappings |
0. What was measured, and on what
Everything below was produced on 2026-08-11, on win-x64, with .NET 10.0.302 and the
Wasmtime 44.0.0 binding.
Reproduction:
dotnet test core/Descent.Sandbox/tests/Descent.Sandbox.Tests/Descent.Sandbox.Tests.csproj -c Debug --filter-class "*WasmDualEngineTests*"dotnet test tests/core/Descent.UnitTests/Descent.UnitTests.csproj -c Debug --filter-class "*SeededDrbgProviderTests*"No Q-ID is minted here. P5 reserves the registry for a figure profiled against a target,
and the one number this round introduces — the community fuel budget — has not been, because no
community cartridge exists to profile. It is sized to be obviously safe in both directions and
says so in its own remarks.
ADR-162 — Descent.RngKit and the ALC cartridge loader are retired without archival, and the DRBG is carved out of the deletion
Status: Accepted Date: 2026-08-11 Supersedes 134 (the ALC cartridge loader it replaced); 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
Context
The Product Owner authorised the complete retirement of Descent.RngKit and the C# cartridge
system, with no archival: the platform is pre-release, there are no live rooms to migrate, and both
were classified as technical debt.
ADR-141 had already recorded the precondition, and it is worth quoting because this change is the event it was written for:
“RngKit retires when the C# cartridges themselves become WebAssembly components — the
Ruleset Forgedirection the audit’s §6 evaluates — because that is the change that removes its consumers.”
ADR-149 built those components. plugins/cartridges/ is a cargo workspace producing
descent_cartridge_coc7e.wasm, measured at 157 306 bytes with zero imports. So the precondition is
met and the retirement is authorised.
The precondition is met for what it names, and what it names is not everything inside RngKit.
The finding, and why it overrides the brief
Descent.RngKit was 87 source files and 11 839 lines across four projects. Of that, one file and
one interface are not a ruleset-authoring API: SeededDrbgProvider and IRandomProvider.
Three separate rules make that file load-bearing, and none of them is about cartridges:
-
It is a published wire contract.
SeededDrbgProvider.AlgorithmIdisDESCENT-DRBG-HMACSHA256-CTR-v1, and its own remarks state that changing any of its four rules “invalidates every previously issued proof”. A roll is verifiable exactly insofar as a third party can replay it from a revealed key. -
It is the authority’s dice (ADR-016, ADR-017).
SeededDrbgActionRandomSourcebinds it toIActionRandomSource, the portApplicationdeclares and deliberately ships no default for. Deleting it would leave the silo with no generator behindGameActionContext.Random, andDescent.ReplayTestsdepends on a stream rehydrating to the state it was written from. -
ADR-149 decision 8 makes it MORE load-bearing after the cutover, not less. “A ruleset cartridge draws no dice.
input.rollscarries d100 results the authority that owns them already drew.” The wasm cartridge is a pure function over dice it is handed. Something must hand them over, and this is that something. Deleting the generator in the same change that made every cartridge depend on being given its dice would have been the two halves of one decision pulling in opposite directions.
So the deletion is executed in full except for the carve-out, and the carve-out is argued rather
than quietly taken. core/Descent.Drbg holds the two types with their behaviour untouched. The
remaining 11.6k lines — the parser, a dozen evaluators, exploding dice, pools, keep-highest, card
mechanics, table lookup, flow control, telemetry, the serialization context — are deleted, because
those are the ruleset-authoring API ADR-141 named and their consumers are gone.
Decision
-
core/Descent.RngKit/is deleted, not archived, along withplugins/Descent.Vtt.Plugins.BRP,plugins/Descent.Vtt.Plugins.CoC7e, their suites,AlcCartridgeLoader,CartridgeLoadContext,AlcCartridgeLoaderTests,rngkit-ci.yml,rngkit-release.ymlandDescentVTT.Plugin.CoC7e.slnx. 41 349 lines removed. -
SeededDrbgProviderandIRandomProvidermove tocore/Descent.Drbgunchanged. The namespace moved; nothing below theAlgorithmIdline did. A namespace is not part of the wire contract and the constant that is part of it is untouched. -
It stays under
core/, which preserves ADR-111’s licensing statement.core/is the statement — “the directory is the licensing statement” — so moving a proprietary generator tomodules/beside the public-NuGet SDK would have been a licensing change made in passing. -
A thin
core/Directory.Build.propsis added, becauseDescent.Drbgis the first .NET project undercore/that is not insideDescent.RngKitorDescent.Sandbox. Both of those carry their own root props that stop MSBuild’s walk; without this file the new project would have inherited the root props and silently lostTreatWarningsAsErrors. architecture-rules §6 names that failure in the abstract — “a build gate into a no-op with no visible change” — and this is its first concrete instance. Verified rather than assumed:Descent.Sandbox’s own props file does not import upward, so the walk still stops there. -
ADR-141’s exemption does NOT end, and clause 5’s prediction is corrected rather than honoured. It said the exemption “ends when one implementation ends” and that “the count is expected to return to one”. Deleting RngKit looks like that event and is not it: the C# side survived the deletion, so there are still two implementations of one frozen algorithm and
WasmCoreDrbgParityTestsis still a condition of admitting the second. The suite followed the type to its new namespace and was not regenerated from either side. -
The
Descent.RngKit.Serializationreference inDescent.Vtt.Server.csprojwas already dead. Its comment claimedRngKitJsonContextservedRngAuditPacket“on the REST/telemetry edge”; no file in that project named either type. Removed, and recorded here because a reference documenting a consumer it does not have is how a reader concludes the edge exists.
What was NOT preserved, stated rather than left to be discovered
- The CoC 7e and BRP rulesets are gone from the C# tree entirely. Their Rust successors exist (ADR-149) and nothing routes a room to them — see ADR-163’s owed work. For a period this repository has no executable ruleset on the product path. That is the honest state and it is the direct consequence of a cutover the owner authorised knowing there are no live rooms.
- The integration suites now drive a test double,
CoC7eTestRulesetEngine, whose attribute vocabulary is copied from the deleted cartridge rather than invented — includinghitPointsMaximumstayingDeprecated, becauseAttributeAuthorisationFlowTestsasserts ADR-071’s deprecated-but-writable window and a double that promoted it would leave that assertion passing against a distinction it had stopped making. It does not reproduce the arithmetic: derived hit points, the Sanity ladder and the dead-investigator refusal are gone with the suite that tested them. AlcCartridgeLoaderTestsis deleted and not re-expressed. It asserted that a cartridge’s dependencies resolve inside its ownAssemblyLoadContext, thatDescent.Vtt.Sdkdoes not, and that the context is collectible. None of the three has a wasm analogue — a.wasmmodule has no dependency graph to resolve and no context to collect — which is most of ADR-163’s argument for the swap rather than a gap in it.
Alternatives Considered and Why Rejected
- Deleting
SeededDrbgProvidertoo, as the brief said. Rejected on the three rules above. The silo would have had no generator behindIActionRandomSource,Descent.ReplayTestswould have had nothing to rehydrate against, and ADR-141’s parity gate would have lost its oracle in the same commit — leaving the Rust DRBG as an unchecked second implementation of a contract whose whole value is that it can be checked. - Keeping RngKit whole and marking it deprecated. Rejected: it is the outcome the owner
explicitly refused, and 11.6k lines of unreachable evaluator is exactly the “temporary” that
P8describes becoming permanent. - Moving the DRBG into
Descent.Vtt.Infrastructuredirectly. Considered, and it would have removed a project. Rejected onP8: the generator is a swap point with two consumers (the composition root and the parity gate), and burying it in the persistence composition root would make the one independent correctness reference in the dice path reachable only through fifteen package references. - Archiving RngKit to a separate repository. Rejected as the thing the owner declined. It also
fails
P6: an archived copy of a deleted engine is a second document that is correct on the day it is written.
Consequences (including negative)
- Three solutions, not five.
DescentVTT.Plugin.CoC7e.slnxis gone andFull.slnxno longer contains aplugins/folder. CLAUDE.md §5’s list is now wrong in a way this round owns. - Two
Directory.Packages.propshubs, not three. architecture-rules §6 states three “per ownership boundary” as an invariant; one boundary ceased to exist. Descent.Vtt.Sdk’sIRulesetEnginehas no external implementor left. It was a public authoring contract; it is now a host-internal adapter interface. That makes changing it cheap for the first time, which is what ADR-163’s owed work depends on.- The DRBG’s own suite lost its NetArchTest dependency. RngKit carried that package for one
assertion; no other suite in this repository has it. The replacement reads the assembly’s TypeRef
table through in-box
System.Reflection.Metadata, which answers the same question one level closer to the artefact, and asserts a positive control so an empty scan cannot pass forever.
Enforcement
SeededDrbgProviderTests.KnownAnswerVectors_AreStable— the carve-out’s own gate. Twenty literals, unchanged, cross-checked at freeze time against a reimplementation written from the published contract alone. This is what makes “the behaviour is untouched” a checked claim rather than an assurance. Run 2026-08-11: 19 tests, all green.WasmCoreDrbgParityTests— ADR-141’s condition, still live, still comparing both implementations over the same keys in the same order.DrbgAssembly_DoesNotReferenceSystemRandom— the architecture assertion, re-expressed without NetArchTest.- The absence of
core/Descent.RngKit— the deletion is its own control.
§11 Index Line
ADR-162: Descent.RngKit and the C# ALC cartridge system are DELETED WITHOUT ARCHIVAL — 41 349 lines, four RngKit projects, both shipped cartridges, AlcCartridgeLoader, CartridgeLoadContext, two CI workflows and a solution — 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 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 rather than less, since a wasm cartridge draws no dice and takes input.rolls from the authority that owns them — 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 and modules/ would have been a licensing change made in passing. ADR-141 clause 5 predicted the exemption "ends when one implementation ends" and THAT PREDICTION IS CORRECTED: 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, and without it the project 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. (Supersedes 134; Depends-on 141; Depends-on 149.)
ADR-163 — Wasmtime is the exclusive cartridge host: an AOT compilation cache it evicts itself, and two engines that differ by fuel alone
Status: Accepted Date: 2026-08-11 Depends-on 162 (which deleted the only other loader); Depends-on 137 (the engine, and clause 3’s zero-import invariant that this narrows the brief against); interacts with 020, 033, 100, 132, 134, 136, 147, 149
Context
With the ALC loader deleted there is exactly one way to run a ruleset, and the loader that does it
had to be built. The brief specified three things: a WasmCartridgeLoader over
IWasmSandboxEngine, Wasmtime’s native AOT compilation cache with a size limit and a cleanup
interval, and a dual-engine split segregating official from community cartridges.
Two of the three are adopted as specified. The third is adopted on one axis and refused on the other.
Decision
-
WasmCartridgeLoaderreplacesAlcCartridgeLoaderbehind the unchangedICartridgeLoaderport.Applicationnever learns that loading changed, which is the property the port existed for. Every failure is still an archivedCartridgeLeaseand never an exception, because ADR-020 requires the room to open either way. -
The signed manifest now declares what the assembly used to expose. A
.wasmmodule exports three symbols and a byte range (ADR-147) and has no type system to enumerate, sorulesetsandactionsare stated incartridge.jsonrather than reflected out of an image. This is the better end of the trade: what a cartridge claims to serve is now something the publisher signed rather than something the host inferred. -
The module’s digest must be in the signed file set, or the cartridge is refused. A module named by a signed manifest but absent from
filesis an unsigned payload selected by a signed document, which is the exact gapfilesexists to close. -
Wasmtime’s on-disk AOT cache is enabled, and no C# deletes an entry. The knobs the brief named —
files-total-size-soft-limitandcleanup-interval— are keys in a TOML document thatwasmtime_config_cache_config_loadparses; there is no binding-level equivalent, soWasmCompilationCachewrites the document and the host supplies numbers. The refusal to write eviction code is the load-bearing half: a cached.cwasmismmapped by every store using it, so a C# sweep deleting one under a live guest is a use-after-free in native memory on Linux and a sharing violation on Windows — one bug, two unattributable failures. Wasmtime’s LRU worker runs inside the runtime that holds those mappings. -
Two engines, and the axis is fuel. The untrusted engine meters (
consume_fuel = true, budget injected per host configuration); the trusted engine does not. Both inherit every other ceiling from the host’s singleWasmSandboxOptions— a first-party cartridge is not given a bigger heap, only an unmetered one. -
“Extended WASI privileges” for the trusted engine is REFUSED. ADR-137 clause 3 makes zero imports the first invariant, and
WasmSandboxEngine.DescribeShapeFailurerefuses any importing module before instantiation. That check is the whole of the containment story — its own remarks say the Jint path spends several hundred lines reaching a weaker version of it — and a trusted engine with a populatedLinkerwould convert a structural boundary into a reviewed one, whichP3orders strictly below. A trusted tier buys the removal of instruction counting. It does not buy a capability. -
Turning fuel off does not leave an execution unbounded, and that is why clause 5 is admissible. Epoch interruption is independent of fuel and stays on in both configurations, so
MaxExecutionTimestill ends a runaway trusted guest. What is lost is a deterministic bound, not a bound — fuel is the same number on every machine and a wall clock is not. That is acceptable for a reviewed first-party cartridge and is precisely what makes it unacceptable for a community one. Measured rather than reasoned: see below. -
The trust tier is HOST configuration and is never read from the manifest. A publisher signs their own manifest, so a
"trust": "official"field would be a self-granted privilege — any community publisher whose key the host trusts could skip metering by signing for it.OfficialPublishersnames which trusted keys are first-party, and a name there that is not inTrustedPublishersfails startup: it is either a typo or a belief that something is trusted when no key verifies it. -
An empty
OfficialPublishersis the safe value and is not refused, unlike an emptyTrustedPublishers, which is. The two differ because one is a trust anchor and the other is a relaxation: empty anchors mean “verify nothing” and empty relaxations mean “meter everything”. -
A cartridge whose manifest still names an
entryAssemblyis refused by name.EntryAssemblyis retained on the manifest for that one purpose. An operator mid-upgrade needs to be told which of their cartridges are stale, not that their manifests are malformed.
The measurements
Run 2026-08-11, win-x64, Wasmtime 44.0.0. WasmDualEngineTests, 7 tests, all green.
| Result | |
|---|---|
Config.WithCacheConfig exists in the 44.0.0 binding | Yes |
Native library carries wasmtime_config_cache_config_load | Yes |
Native library carries files-total-size-soft-limit, cleanup-interval | Yes |
| The generated TOML is accepted and the engine executes | Yes |
Trusted engine (consume_fuel = false) executes an ordinary guest | Yes |
| Trusted engine still stops a spinning guest on the epoch deadline | Yes |
| Either engine admits an importing module | No |
The API surface was verified before the code was written rather than after, because the whole
design rests on a cache the binding might not have exposed. The last three rows are the ones worth
their place: row 5 is the arm that catches an unconditional store.Fuel = …, which throws on an
engine that does not meter and would have made every trusted execution a host fault on its first
call; row 6 is decision 7 as a test rather than an assumption; row 7 is decision 6.
Alternatives Considered and Why Rejected
- Granting the trusted engine WASI, as the brief specified. Refused on decision 6. It is the one clause of the brief that would have reopened ADR-137’s first invariant, and the tier it was meant to serve does not need it: a ruleset cartridge is a pure function over an envelope.
- Writing a C# LRU over the
.cwasmdirectory. Refused on decision 4’smmapargument. The brief explicitly warned against it and the warning is correct. - Reading the trust tier from the signed manifest. Rejected on decision 8. A signature proves who wrote a claim, never that the claim is one they were entitled to make.
- Named options or keyed services in
Descent.Sandboxto serve two engines. Rejected on blast radius.Descent.Sandboxis a proprietary core module with its own conventions and its own CI; making its DI serve two differently-configured engines would put a silo composition decision inside it.WasmSandboxEngine’s constructor takes its options directly, so the silo composes what the silo needs and every other consumer keeps the single-engine registration. - One shared module cache between the engines. Rejected: a compiled
Modulebelongs to theEnginethat produced it, so sharing one would hand a module compiled under one configuration to a store created by the other. wasmtime refuses that at instantiation, and the symptom would be a cartridge that works or does not depending on which engine compiled it first. - Two cache directories, one per engine. Rejected as re-deriving a separation the runtime already makes: a compiled artefact is keyed by its bytes and the configuration that produced it.
Consequences (including negative)
-
WasmRulesetEngine.Executethrows, and this is the round’s largest owed item.IRulesetEngine.Executeis synchronous andRoomGraincalls it inside a mailbox turn, under a comment explaining why that was safe for a .NET cartridge: “a signed P0 cartridge runs in-process with host privileges by design, and .NET cannot preempt it. The protection against a runaway cartridge is the signing and review gate, not a timeout that does not exist.” A wasm cartridge inverts every clause — the protection is a timeout that does exist — and reaching it goes throughIWasmSandboxEngine, which queues on a bounded admission channel and runs on a dedicated stack thread pool. architecture-rules §3 forbids that inside a mailbox twice over: class 1 (sandbox execution) and class 4 (await-ing any dedicated pool at all).The options were a blocking call that violates §3 silently or a refusal that says so loudly. This is the refusal, and it is
P3’s strongest tier applied to a migration seam: a room routed to a wasm cartridge before the grain’s action path is dispatched fails immediately with a sentence naming the rule, rather than stalling every player behind a queue nobody can see. -
So nothing routes a room’s action to a cartridge yet.
ExecuteAsyncis the real surface and is complete; what is missing is the caller.RoomGrain.SubmitActionAsyncmust become dispatch-and-reenter — the idiom hydration, cartridge acquisition and authorisation already use — and that changesIRoomGrain’s contract and the client-visible semantics of an action, so it is an ADR-sized decision rather than a cleanup. This is the same “architecture with no consumer” shape R36 recorded against the plugin worker and R37 against the wasm32 ruleset tier, and it is named rather than absorbed. -
WasmRulesetEngine.Schemais an empty vocabulary, so nothing is writable. The ALC loader read a schema off a compiled property; negotiating one across the guest ABI is owed. The fail-safe direction was chosen deliberately: a permissive stand-in would make every key writable on a cartridge nobody has reviewed, which inverts what ADR-071 uses the schema for. -
Mutation decoding across the guest ABI is not implemented.
ExecuteAsyncreads a refusal and otherwise reports that it proposed nothing. The envelope and the transport are real; the vocabulary on the way back is not. -
UntrustedFuelBudgethas a default whereWasmSandboxOptions.MaxFueldeliberately does not. ADR-100’s no-defaults rule is about the library — a value there ships to every host. This is the silo’s own composition and the alternative is a silo that will not start until somebody picks a fuel figure for a tier they may not be running. It carries noQ-IDand is not measured. -
sandbox-ci.ymlnow runs three legs instead of two. macOS was added because Wasmtime is a per-RID native library and ADR-162 made it the only way this platform runs a ruleset — before the cutover a broken macOS binding cost a creator a local preview; after it, a room cannot resolve an action. macos-latest is arm64, making this the only job in the repository exercising a non-x64 target. -
That leg immediately found a defect, and it is not the one it was looking for. The Wasmtime binding is fine on macOS; the Jint path is not.
SandboxEngine’s constructor builds aDedicatedStackThreadPoolreservingMaxStackSizeBytes— 16 MB — per worker, and with xunit running collections in parallel the macos-latest runner (arm64, 7 GB, against 16 GB on the other two) throwsOutOfMemoryExceptionfromThread.StartInternalbefore a single script executes. The macOS leg is therefore scoped to the WebAssembly namespace, which is the 93 tests it was added for, and Linux and Windows continue to run all 673.Recorded as a finding rather than repaired in passing, for two reasons that both point the same way: the stack ceiling is a containment control, and changing one to make a build green is what CLAUDE.md §6 rule 4 forbids; and this is a pre-existing property of a proprietary module that predates this round entirely — nobody had ever run that suite on macOS.
--max-threads 1is the obvious hypothesis and is untested, which is exactly why it is written here as a hypothesis rather than applied as a fix.
Enforcement
WasmDualEngineTests— the seven rows above, on all three platforms.CartridgeLoaderOptionsValidator— decisions 8 and 9 at startup: an official publisher that is not a trusted one, a zero fuel budget, and cache settings without a cache path.WasmRulesetEngine.Execute’sNotSupportedException— the §3 boundary, as a type rather than as a review comment.sandbox-ci.yml’s three-OS matrix — the native binding.WasmCartridgeLoaderTests— 18 tests over the signature, the digest, the traversal, the trust tier, discovery and the lease lifetime, against real ECDSA keys and real files on disk. Added after the first draft of this ADR, whose Enforcement said the loader had no test. The engines are fakes and the verifier is not: faking the verifier would have left every check this suite exists for asserted against a stub that always agreed.- The mutation sweep below — what makes the line above a control rather than a count.
- Not enforced, and stated as
P4requires: nothing checks that a cartridge directory’srulesetsare ids any room actually binds to, and nothing runs the loader end-to-end against a real cargo-built.wasmartefact — the suite’s module bytes are arbitrary, because the loader never parses WebAssembly.
§11 Index Line
ADR-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 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 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 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. 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, 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), mutation decoding across the guest ABI is unimplemented, and WasmCartridgeLoader has NO TEST OF ITS OWN. (Depends-on 162; Depends-on 137.)
The mutation sweep
Thirteen mutations, all caught. Run 2026-08-11 against the committed green baseline, one at a time, each reverted before the next, every run bounded — a hang is not “caught”. The targets are ADR-162’s and ADR-163’s decisions rather than the code that carries them.
The first attempt proved nothing about five of them, and that is the finding worth recording.
Written as a constant false, the signature, urn, traversal, file-set and ruleset guards all became
unreachable code, which TreatWarningsAsErrors rejects at compile time. A mutation the compiler
refuses is caught by a stronger gate than a test and says nothing about the suite — this is
exactly what R37’s mutation 9 recorded, rediscovered by walking into it. Rewritten so each is opaque
to constant folding and false at run time, the five actually reached the tests, and two of them
survived.
| # | Mutation | Caught by |
|---|---|---|
| 1 | The signature is computed and its result discarded | AManifestSignedByAnUntrustedKeyIsRefused |
| 2 | The manifest’s urn is compared to itself rather than to the request | ASignedManifestDeclaringADifferentUrnIsRefused — after repair |
| 3 | The module’s digest is not compared | AModuleEditedAfterSigningIsRefused |
| 4 | The traversal guard compares the path to itself | AModuleOutsideItsOwnDirectoryIsRefused |
| 5 | The file-set lookup uses the wrong key | AModuleAbsentFromTheSignedFileSetIsRefused |
| 6 | Every publisher is treated as official | TheTrustTierComesFromHostConfigurationRatherThanTheManifest |
| 7 | The tier flag is right and the engine is always the trusted one | as above ×1 |
| 8 | A .NET cartridge gets the generic refusal | ADotNetCartridgeIsRefusedByNameAndToldToRebuild |
| 9 | An empty ruleset set is admitted | ACartridgeWithNoUsableRulesetIdsIsRefused… — after repair |
| 10 | The sweep drops a cartridge a room still holds | ACartridgeIsSharedAcrossRoomsAndSweptOnlyWhenNobodyHoldsIt |
| 11 | Two directories claiming one urn is last-one-wins | TwoDirectoriesClaimingOneUrnIsRefused |
| 12 | One engine supplied twice is accepted | OneEngineSuppliedTwiceIsRefused |
| 13 | Synchronous Execute refuses quietly instead of throwing | SynchronousExecutionIsRefusedAndNamesTheMailboxRule |
The two survivors were test defects, and both are the shape this corpus keeps recording.
Mutation 2 survived because the first version of that test asserted NotFound. The index maps
urn to directory by parsing manifests without verifying them, so a directory that already declared
a different urn simply never claims the one being asked for — the request was refused before
reaching the comparison at all, and the test was green for the wrong reason. The repair re-signs
the manifest after the loader is constructed, which reproduces the time-of-check-to-time-of-use
window the loader’s own index comment describes and is the only way to reach that branch.
Mutation 9 survived because both arms answer CartridgeArchiveReason.NoEngine. With the
empty-set guard weakened, the loop over an empty collection adds nothing and execution falls through
to the blank-ids refusal — the same reason code for a different fact. A test asserting only the
reason cannot tell them apart. The repair asserts the detail, and covers both arms, because a
publisher who declared nothing and one who declared blanks need different advice.
The second sweep — ADR-164
Eight mutations, seven caught. Run 2026-08-11 against the committed green baseline, same
discipline: every mutation written to COMPILE, because R37’s mutation 9 and this round’s own first
sweep both recorded that a constant false becomes unreachable code the compiler rejects.
| # | Mutation | Outcome |
|---|---|---|
| 1 | The async fork is never taken | Caught |
| 2 | The per-actor bound never refuses | Caught |
| 3 | Re-entry never releases the actor | Caught |
| 4 | A voided batch is applied regardless | Caught — after repair |
| 5 | An unknown completion is not discarded | Caught — after repair |
| 6 | actor.missing is not distinguished from a generic refusal | SURVIVED |
| 7 | The cartridge is handed the LIVE actor | Caught — after a new test |
| 8 | The snapshot records the wrong revision | Caught |
Mutation 7 is why DispatchedActorSnapshot has a test at all. It survived the whole original
suite: with nothing else moving the actor, a premise of “one behind” is stale whether it was read
at dispatch or at completion, so every existing test passed with the freeze removed.
AStalePremiseFromARealRaceIsDiscarded was written to catch it, and it reproduces the race rather
than simulating it — actor A’s cartridge is held open while actor B’s runs to completion and
proposes against A, moving it. A snapshot is correctly refused; a live actor would match and
apply.
Mutations 4 and 5 survived for one shared reason, and it is the more general lesson. The
discard tests asserted absence — nothing applied, nothing queued — and every mutation that
broke the discard logic also produced absence, because the failure threw and the background
task’s catch swallowed it. “Correctly discarded” and “crashed on the way to discarding” were
indistinguishable to the suite. The repair was CartridgeDispatchOutcomeProbe, which reads the
production counter so a test can assert which branch ran. That also puts ADR-164’s own P4
enforcement point under test: a metric nothing ever reads is a rule with no evidence behind it.
The probe immediately found a test of mine that was green for the wrong reason.
AStalePremiseIsDiscardedRatherThanApplied set a premise of revision - 1 on a fresh actor at
revision 0 — and MutationProposal.TryCreate refuses a negative expected revision, so the
cartridge returned a refusal and the stale branch was never reached. Absence looked identical.
The test now performs one real strike first, so the actor is at revision 1 and “one behind” is
genuinely stale.
Mutation 6 survives and is recorded rather than papered over. No grain method withdraws an
actor, so the actor.missing state is unreachable from outside the aggregate and its telemetry tag
cannot be distinguished by a test. It is a tag, not a control — unlike the guard below, it cannot
mislead a reader into thinking a check exists.
One line was deleted because a mutation proved it could not fail. Re-entry re-found the actor
and returned early if it had gone; ProposeBatch already refuses with actor.missing, so the
guard was “a line that made a reader believe a second control existed” — the same disposition
CartridgeLease.Dispose and AlcCartridgeLoader’s Drop already carry, for the same reason. The
specificity it provided is kept by reading the error code the aggregate returns.
Neither mutation was adjusted to make the suite look better. Both tests were wrong and were repaired; the mutations are unchanged and were re-run against the repairs.
ADR-164 — Cartridge execution leaves the mailbox by dispatch-and-reenter, and the premise is ADR-046’s
Status: Accepted
Date: 2026-08-11
Depends-on 163 (whose owed work this is: WasmRulesetEngine.Execute threw because nothing
could call it); 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
Context
ADR-163 shipped a cartridge host nothing could reach. IRulesetEngine.Execute is synchronous and
RoomGrain called it inside a mailbox turn under a comment explaining why that was safe for a
.NET cartridge: “a signed P0 cartridge runs in-process with host privileges by design, and .NET
cannot preempt it. The protection against a runaway cartridge is the signing and review gate, not
a timeout that does not exist.”
A WebAssembly cartridge inverts every clause. The protection is a timeout and it does
exist; reaching it goes through IWasmSandboxEngine, which queues on a bounded admission channel
and runs on a dedicated stack thread pool. architecture-rules §3 forbids that inside a mailbox
twice over — class 1 (sandbox execution) and class 4 (await-ing any dedicated pool at all).
So WasmRulesetEngine.Execute threw, and nothing routed a room’s action to a cartridge.
Decision
-
The fork is decided by the engine’s TYPE. An engine that cannot answer in a turn implements
IAsyncRulesetEngine;RoomGrainpattern-matches and dispatches. The room’s behaviour and the engine’s capability therefore cannot drift apart, and the two halves of the rule agree — the interface says “dispatch me” andExecutesays “you did not”. -
The synchronous arm survives. An engine that can answer in a turn keeps the contract where a refusal is a return value. Making every action asynchronous to serve the engines that require it would turn every unknown action and every negative argument into an event a client has to correlate, for no gain on the path that does not need it.
-
Everything cheap stays in the turn. Lifecycle, outbox backpressure, actor lookup, engine resolution, argument parsing and the dice are all decided before dispatch, so a refusal that does not need a cartridge is still immediate.
-
The grain stays NON-reentrant, and
[Reentrant]is refused. The empowerment for this round named it explicitly. It is the wrong tool:[Reentrant]lets other messages interleave at every await point in the activation, which is precisely what ADR-033’s “one message at a time, including acrossawaitpoints” exists to prevent for the authoritative world state — the tick would interleave with a half-applied batch. Dispatch-and-reenter gets the mailbox back without relaxing that guarantee at all: what leaves the turn is a computation that touches no room state, and every mutation still happens inside a turn. -
The actor is FROZEN at dispatch, and this is the subtlest decision here.
Actoris mutated in place —Revisionhas a private setter andApplyMutationadvances it. Hand the live actor to a dispatched cartridge and: A is dispatched at revision 3; something else moves A to 4; A’s cartridge finally readstarget.Revisionand sees 4; it builds a proposal whose premise is 4, which matches; andProposeBatchaccepts a computation performed against a world that no longer exists. The premise would be satisfied by accident — no exception, no conflict, just a mutation derived from stale attributes landing on fresh state.DispatchedActorSnapshotis what makes the premise mean what it says. -
There is NO new ETag, and inventing one was the tempting mistake. ADR-046 already owns staleness: a proposal carries the revision it was computed against, and
ProposeBatchvoids the whole batch onactor.revision.stale. Re-entry runs through the sameProposeBatchas the synchronous path. A second staleness mechanism would be a second answer to one question, and the two would disagree the first time somebody changed one of them. -
One invocation per actor, ADR-151 decision 2’s argument one tier down. Without a bound the queue grows the moment a cartridge is slower than the rate actions arrive, and every invocation after the first is computed against a premise the one before it is about to invalidate — producing a burst of
PremiseFailedinstead of one honest refusal. Per actor rather than per room, because the premise is per actor: two players acting on two different actors do not invalidate each other. -
Dispatchedis a new status, not a reusedAccepted.Accepted’s contract is “validated, applied in memory, and queued for persistence” — three facts, none of them true yet. §7’s naming rule is explicit that a distinction the architecture makes gets its own word. The ingress collapses the two for the player (IntentIngressStatus.Accepted), where the difference is not something anyone can act on. -
The outcome crosses back as an id, not as a value. A
RulesetOutcomeholdsMutationProposalvalues fromDescent.Vtt.Sdk, which is deliberately dependency-free and carries no Orleans serializer attributes. Passing one as a call argument would force a serializer onto the SDK’s public surface to move an object between two points in one process. It is published to a field and adopted in the turn — the ruleOnCartridgeOpenedAsyncalready follows. -
An unrecognised completion is discarded, not an error. The room may have deactivated and rehydrated while the cartridge ran, so the computation belongs to an activation that no longer exists and was performed against a world this one rebuilt from the log. Applying it would be the one thing worse than losing it.
What was measured
Grain.GrainFactory is activation-scoped, and reading it from a thread-pool thread throws.
System.InvalidOperationException: Activation access violation.A non-activation thread attempted to access activation services.Measured 2026-08-11, not anticipated. Every other dispatched stage in RoomGrain — hydration,
cartridge-open, authorisation — resolves the factory inside an async grain method that is still
on the Orleans scheduler, so none of them had to say this and the idiom looked safe to copy.
Task.Run leaves that scheduler. The reference is now captured before the task starts and only
used inside it.
Alternatives Considered and Why Rejected
[Reentrant]onRoomGrain. Refused on decision 4. It relaxes exactly the guarantee the tick depends on, to solve a problem dispatch solves without relaxing anything.- Making every action asynchronous. Rejected on decision 2.
- A bespoke ETag or version token on the dispatch. Rejected on decision 6.
- Passing the live
Actorto the cartridge. Rejected on decision 5 — and it is the alternative that would have looked correct in review, because the defect it produces is a check that passes. - Bounding in-flight invocations per room. Rejected on decision 7’s second half.
- Blocking on the sandbox inside the turn. The status quo ADR-163 refused to ship. It is a §3 violation whose symptom is every player in a room stalling behind a queue nobody can see.
Consequences (including negative)
- A dispatched action’s outcome is not in its reply. A cartridge refusal, a stale premise and a
departed actor all produce
Dispatchedfollowed by nothing. That is what the counter below is for, and it is the real cost of this shape. - Work is lost if the room deactivates mid-flight (decision 10). The loss window §7.1 already accepts for unpersisted events widens to include computed-but-unadopted ones. Amended 2026-08-11: mitigated, and the mitigation is narrower than the sentence it replaces. See “The in-flight saga” below.
ActorBusyis a status clients have not seen before. A UI that treats every non-Acceptedstatus as an error will show it as one; it is backpressure and the correct response is to retry.- The room’s own
Actoris still mutable, and nothing structural stops the next author handing it to a cartridge.DispatchedActorSnapshotis a type they must choose to use — the mutation sweep is what holds it, not the compiler.
Enforcement
DispatchedCartridgeTests— 8 tests: the status, the mailbox still turning while a cartridge is provably mid-flight, re-entry applying, the per-actor bound, two actors concurrent, a simulated stale premise, a real race, and an orphaned completion.DispatchedActorSnapshotTests— 3 tests driving the real aggregate, pinning that a snapshot does not follow the actor it was taken from.descent.room.cartridge.dispatch.outcome— the counter, taggedapplied,refused,premise-stale,actor-gone,batch-refused,orphaned. This is ADR-164’s P4 enforcement point: a dispatched action that ends in any way butappliedproduces no event and no return value, so without the counter it produces no evidence either, and “actions sometimes do nothing” would be a report nobody could investigate.orphanedand a risingpremise-staleare the two worth alerting on.- Not enforced, and stated as
P4requires: theactor-gonetag is not distinguished by any test, because no grain method withdraws an actor — the state is unreachable from outside the aggregate. A mutation collapsing it intobatch-refusedsurvives, and that is recorded rather than papered over.
The in-flight saga — amended 2026-08-11
The consequence above said §4’s rule that “an in-flight saga keeps the room Active” was the right
fix and was not implemented. Implementing it turned up something worth recording: §4’s Dormant
state does not exist in code.
RoomTickScheduler ticks every registered room. There is no suspension, no “no pending work”
evaluation and no Dormant transition anywhere — the state is a documented rule with nothing
behind it, and FogTier.Dormant is a different thing entirely (fog cadence for a stale viewer).
So there was no “Active” to keep a room in, and a change phrased as keeping one there would have
been a change to nothing.
What actually loses the computation is Orleans’ idle collection, and that is real. The
dispatched task holds no reference to the grain — it captures values and an IGrainFactory, which
is what makes it safe to run off the mailbox — so nothing keeps the activation alive on its own.
A room whose only outstanding work is off-mailbox is idle by every measure Orleans has.
The fix is DelayDeactivation at dispatch, bounded by
RoomTickOptions.CartridgeDispatchKeepAlive. It extends the idle timeout rather than pinning the
activation, so a silo shutdown or a rebalance still takes the room — and losing the computation
then is correct, because the room rebuilds from the log and the premise would be stale anyway.
Two things it does not do, stated rather than implied. It does not implement §4’s Dormant
state, which remains unbuilt. And nothing checks that the keep-alive exceeds the sandbox’s own
MaxExecutionTime: that value lives in Descent.Sandbox, a module Application may not name
(architecture-rules §5), so the two numbers cannot be compared where either is defined. An operator
who raises the cartridge budget above the keep-alive gets orphaned work rather than an error, and
the orphaned tag on descent.room.cartridge.dispatch.outcome is what reports it.
The DelayDeactivation call itself has no test. It is a call into the Orleans runtime whose
effect is an idle-collection deadline, and this repository has no fixture that advances one. What
is observable is the counter, in production. Recorded as a gap rather than covered by a test that
would only assert the line exists.
A zero-allocation snapshot was proposed, measured, and refused
A follow-up proposed converting DispatchedActorSnapshot from a sealed class to a
readonly record struct, on the premise that the snapshot would then be “inlined directly into
the Task.Run closure, eliminating the Gen 0 heap allocation”.
Measured 2026-08-11, win-x64, .NET 10.0.302, over 10 000 iterations after a warm pass:
| Shape | Bytes per operation |
|---|---|
sealed class (shipped) | 56.0 |
readonly record struct | 56.0 |
Identical, because IAsyncRulesetEngine.ExecuteAsync takes an IActor. A value type passed
as an interface is boxed, and the box is the same Gen 0 object the class was. The conversion trades
one allocation for another and buys nothing — the premise is refuted on its own terms, in the shape
ADR-154 already established for a hub-protocol allocation argument that also turned out to be zero.
It is refused on a second ground that outlives the measurement. A struct has a default, and
default(DispatchedActorSnapshot) would be a silently valid premise: actor Guid.Empty at
revision 0 — which is exactly the revision a freshly admitted actor has. This type’s entire
purpose is to carry a premise that ADR-046 checks, so a default that reads as “revision 0” is the
one value most likely to match by accident. The corpus already refuses this shape twice:
CartridgeArchiveReason has no None = 0 because “a field somebody forgot to assign would
silently re-open a room”, and RulesetOutcome’s default is a refusal for the same reason. A
class cannot be defaulted into a plausible-looking premise; null fails loudly.
Scale is the third answer. One dispatch already allocates the argument bag, a per-action DRBG
with its own buffers, a Guid[] of event ids, a closure display class, an async state machine box
and a Task. Removing 56 bytes from that list is a rounding error with a correctness risk
attached.
DispatchedActorSnapshotAllocationTests keeps both figures, inverted into the invariant the
measurement establishes rather than deleted: if the engine contract ever stops taking an interface
— a generic parameter, a ref struct — the boxing assertion fails and the struct becomes worth
revisiting. A comment saying “we measured once” is not that.
§11 Index Line
ADR-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. Dispatchedis a new status rather than a reusedAccepted, 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.)
A correction: the cache test broke the rule this round wrote
ADR-163 decision 4 says nothing in C# may delete a Wasmtime cache entry, because the worker that
owns that directory lives inside the runtime. TheGeneratedCacheConfigIsAcceptedByWasmtime then
deleted the cache directory in its own finally.
Its comment argued the distinction away: “deleting a cache this test created after its engine is disposed is cleanup; deleting entries out from under a live store is the use-after-free that argument is about.” That reasoning is wrong, and it was wrong in the way this corpus keeps recording — a plausible sentence standing in for a checked fact.
It passed on win-x64, passed on the pull request, and failed on main with
IOException: Directory not empty. Wasmtime’s cleanup worker is a thread that briefly outlives
Engine.Dispose and is still writing; a recursive delete enumerates, then unlinks, and loses the
race when a .cwasm appears in between.
The repair is the ADR’s own argument applied one level down: nothing in C# races that worker, and
a test is not an exception. The delete became best effort with IOException swallowed, on a
uniquely-named temp directory the OS reclaims anyway. It is not a retry of a flaky assertion —
§14.9 forbids that outright — because it removes the race rather than tolerating it, and every
assertion in the test is untouched.
The lesson is about where a rule stops being applied. The rule was written, argued and enforced in production code in the same change whose test violated it, which is the “two individually correct mechanisms produce a defect at their intersection” shape architecture-rules §9 names — here, one mechanism and its own test.
What this work owes and has not paid
P7 and CLAUDE.md §6 rule 5.
Nothing routes a room’s action to a wasm cartridge.Paid by ADR-164, which is in this document rather than a later one because the debt was this round’s own. What remains of it is narrower and is stated in ADR-164’s consequences: a room that deactivates while a cartridge is computing still loses the computation, because §4’s rule that an in-flight saga keeps a room Active is not implemented.- The platform has no executable ruleset on the product path for the first time. The Rust cartridges exist and are unreachable; the C# ones are deleted.
- Mutation decoding and schema negotiation across the guest ABI are unimplemented.
UntrustedFuelBudgetis unmeasured and carries noQ-ID.- The Jint engine is unverified on macOS, and now known to fail there for a resource reason
rather than an unknown one. See ADR-163’s consequences. Whoever picks this up should try
--max-threads 1before touchingMaxStackSizeBytes, and should not touchMaxStackSizeByteswithout an argument, because it is a containment ceiling. - The Marketplace and Studio contexts were not built or run.
ADR-M-001and ADR-152 keep them physically isolated and nothing here touches them, but CLAUDE.md §5’s trap is worth restating: a green VTT build says nothing about either. docs/backend/README.md,docs/backend/HANDOVER.mdand the rule files carry stale statements aboutplugins/, RngKit and the three props hubs.CLAUDE.md§5 and.claude-rules/architecture-rules.md§5/§6 are updated by this round; thedocs/backend/pair is not, and both already carry dated staleness banners.
ADR-165 — Gas metering is the frontend plugin tier’s inner preemption, Worker.terminate() stays as the outer one, and QuickJS is refused again
Status: Accepted Date: 2026-08-11
Context
The R39 Phase 2 brief proposed pivoting away from ADR-146’s one-worker-per-plugin topology to a
single worker hosting every frontend plugin, on a scalability concern, and proposed replacing
Worker.terminate() with a two-tier in-band defence: WebAssembly gas instrumentation for
native plugins, and QuickJS with JS_SetInterruptHandler for TypeScript/JavaScript plugins.
Two of those four things are new, one is already decided, and one rests on a premise this repository can check.
ADR-146 closed Spike S6 with the finding that a synchronous descent_invoke cannot be
interrupted in a browser, “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 became Worker.terminate(), measured at 2.19 ms on a
spinning guest.
That sentence is correct about the two mechanisms it names, and it is not a statement about every mechanism. Both rejected designs move information inward at run time — the host tells a running guest to stop — and inbound run-time communication in WebAssembly is spelled “import”. Spike S7 asked whether anything preempts a guest without moving information inward.
Decision
1. Gas metering is adopted as the inner preemption tier, and it costs no import.
tools/wasm-gas rewrites a zero-import guest to carry a mutable i64 global that the host
writes before the call and the guest decrements at every function entry and every loop
header, executing unreachable when it would go negative. The global is exported, and an export
is an outbound name: the host installs a budget with instance.exports.descent_gas.value = n, which
needs no host edge at all. The rewritten module still declares zero imports, so ADR-144’s prize
— one artefact wasmtime instantiates in the silo and WebAssembly.Instance in the browser —
survives intact.
Charging at function entries and loop headers intersects every cycle in the control-flow graph:
execution can only fail to terminate by re-reaching an instruction, and the only two ways to do that
are a loop back-edge and a re-entering call. So the rewriter needs no call-graph analysis, and
recursion — direct, mutual, or through call_indirect — pays because every re-entry is an entry.
2. Worker.terminate() is KEPT, and the brief’s removal of it is overridden.
Gas binds only code that went through the rewriter. Verifying that an arbitrary module is
correctly metered — proving every back-edge is dominated by a charge — is a dataflow problem over
untrusted input, which is precisely the class of work a sandbox exists to avoid performing on a
hostile artefact. So the client instruments or refuses; it does not verify. That closes evasion
but not a bug in the rewriter, and P3’s ordering is explicit that a structural boundary beats an
in-band one. A counter we inject is in-band; a separate worker is structural.
The two are therefore layered rather than alternatives:
| tier | mechanism | remedy | cost |
|---|---|---|---|
| inner | gas exhaustion | catchable RuntimeError, attributed, co-tenants unaffected | a rewrite pass we must trust |
| outer | Worker.terminate() | uncatchable kill of the shared worker | every co-tenant dies |
The outer tier is now a last resort against a metering failure rather than the per-plugin remedy it was under ADR-146, and that is the whole change to its role. It costing every co-tenant is the reason the inner tier had to exist before the single-worker pivot was admissible at all.
3. QuickJS is refused, and JS_SetInterruptHandler has no object in this codebase.
ADR-144 already owns this: QuickJS is C and needs a libc that wasm32-unknown-unknown does not
have. Adopting it means wasm32-wasip1 (eight wasi_snapshot_preview1 imports) or emscripten (JS
glue imports), and either ends the zero-import invariant, which is the entire prize.
The premise that the JS tier needs a second mechanism also does not hold, and the reason is a fact
about the artefact rather than an argument. descent_wasm_core.wasm is already a JavaScript
engine — Boa, in Rust, zero imports (ADR-140, ADR-144). A creator’s while (true) {} is an
unbounded loop inside the interpreter’s own bytecode dispatch, which is inside the module the
meter rewrote. One mechanism reaches both tiers, and it is stronger than an interrupt handler
for ADR-146’s own reason about terminate(): an interrupt callback is polled by the interpreter
and is only as reliable as the interpreter’s loop, whereas a wasm trap unwinds every frame to the
host boundary and no guest code observes it.
4. The World Builder’s JSON AST needs no new sandbox, and gets none.
A visual script is data that a first-party interpreter executes, so it enters through the field
ADR-145’s envelope already has for creator-chosen content — input — and is executed by the same
metered guest. It is therefore metered identically, with no second seam and no second trust
boundary. The rule this fixes in advance: a visual script is not more trusted than typed code
because a GUI produced it. Nothing about a node graph constrains what it compiles to.
What was measured
On V8 (Node 24.18.1), and on wasmtime 47 for the rewrite’s own correctness:
| claim | reading |
|---|---|
| rewrite of the real 3.03 MB Boa payload | 157 ms, 12 955 charge points |
| imports after metering | 0 (unchanged) |
| module validity after metering | valid; every section preserved |
| benign script output, metered vs unmetered | byte-identical |
hostile while (true) {} | traps, RuntimeError: unreachable, budget exactly spent |
| host after a trap | runs the next script to completion, repeatedly |
| same script at 200 000 / 200 000 000 units | traps / completes |
No Q-ID is minted. These are readings, not budgets, and P5 reserves the registry for a
figure profiled against a target. The metered payload is 3 150 296 bytes against 3 180 219 — it
got smaller, because re-encoding normalises LEB128 more compactly than rustc emitted it, and that
is recorded because a reviewer expecting growth would otherwise read the number as a dropped
section.
Alternatives Considered and Why Rejected
- Per-instruction metering rather than per-charge-point. Rejected: the budget bounds time to trap, not fairness, and a coarser meter is a smaller rewrite with less to get wrong. What it costs is precision, and the cost is stated rather than hidden — an exhausted plugin cannot be told which instruction did it.
- Verifying instrumentation instead of performing it. Rejected: a dataflow proof over hostile input, which is the work the sandbox exists to avoid. The exported marker detects an already-rewritten module so a second pass is refused rather than silently doubling every charge; it is not a trust token, since a hostile module can export the same name.
- Instrumenting at build time in the creator’s toolchain. Rejected: an attacker simply does not run it. Metering must happen where the artefact is not trusted.
- Removing
Worker.terminate(), as the brief asked. Rejected onP3— see decision 2. - A returned error code instead of a trap. Rejected: a value the guest may ignore makes the budget advisory.
Consequences (including negative)
- The single-worker pivot becomes admissible, and is not yet implemented. What Spike S7 establishes is the property it depends on; the supervisor still spawns one worker per plugin.
- A metering bug is now a shared-fate bug. Under ADR-146 a runaway plugin cost one worker; under a shared worker it costs every co-tenant, because the only remaining remedy is the outer kill. This is the real price of the pivot and it is why the outer tier is retained rather than removed.
- Gas is not time. A module doing 64-bit divides burns the same gas per point as one doing
i32.addwhile taking longer. ADR-159 draws the same distinction on the silo side. - Memory is not bounded by this.
memory.growis untouched;budget.ts’s heap ceiling remains the only thing watching it. - The rewriter is now on the path of every frontend plugin, so its own defects are the tier’s defects. Its corpus is WebAssembly text (ADR-159’s rule) and it runs under two engines.
- Unbounded recursion never reaches the meter, because
maxRecursionDepthstops it first and returns a cleanRuntimeLimitrefusal naming the limit. That ordering is deliberate and better: a refusal tells the creator what they hit; a trap only tells the supervisor something died.
Enforcement (ADR-045)
tools/wasm-gas/tests/metering.rs— 9 structural assertions, including the zero-import invariant.tools/wasm-gas/tests/execution.rs— 5 behavioural assertions under wasmtime, with wasmtime’s own fuel as an independent watchdog, so a broken meter fails with a sentence naming the cause instead of hanging the suite.src/plugins/gas-instrumentation.test.ts— 11 assertions on V8, including the co-tenancy claim.src/plugins/boa-metering.test.ts— 7 assertions on the real Boa payload.src/plugins/gas-abi.test.ts— readstools/wasm-gas/src/lib.rsand fails if the names drift.
And the CI job that runs them, because until it existed the list above was not an enforcement
point — it was five suites nothing executed. frontend-ci.yml gained three steps and a path
trigger on tools/wasm-gas/**:
- Gas rewriter (clippy + both corpora) —
cargo clippy --all-targets --all-features -- -D warningsandcargo test, because a local clippy exits 0 on lints this pipeline rejects. - Build the gas fixtures — after
Build, since the Boa half needsdescent_wasm_core.wasm. - Refuse a silent skip of the S7 corpus — asserts all four fixtures exist and are non-empty
before
pnpm test. This is the step that matters most and it guards the others. Both browser suites are written to skip when their fixtures are absent, which is correct for a developer without a Rust toolchain and is a hole in CI: a skip prints a warning and leaves the job green, so the pipeline would report this ADR’s evidence as passing on a run that executed none of it. Verified in both directions before it was committed — it passes with the fixtures present and fails naming the missing one when the Boa fixture is removed.
tools/wasm-gas had no CI reference when it was written, and neither do marketplace-wasm-fuzzer
nor descent-asset-baker — ADR-159 says of the fuzzer, in as many words, “nothing runs it”. That
precedent is why this was checked rather than assumed.
Two test defects found by mutation, not by review
Recorded because both are the shape P2 warns about — an assertion that reads as coverage.
a_loop_is_charged_per_iteration_and_not_once_on_entryasserted nothing of the sort. Moving the charge from after theloopheader to before it makes the meter a decoration — an infinite loop then pays exactly one unit — and the charge count is two either way, so every structural test stayed green. Renamed to what it checks; the property is now asserted by executing.- The execution suite’s first form matched on wasmtime’s error string, which is a backtrace and
never names the trap, so it passed for every possible trap. It now downcasts to
Trap.
A third was found by looking rather than mutating: the browser suite’s envelope key was not 64 hex
characters, so the guest refused every call at the envelope gate — and “answers identically”
passed on two identical refusals. The scripts were also written as function main() { … }, which
merely defines a function; a plugin script here is a bare expression whose completion value is the
intent array, so the hostile scripts never ran.
§11 Index Line
| 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 |
Phase 2 — three proposals that already had owners, and what happened to them
P1 says find the owning ADR before changing a claim. Three of the R39 Phase 2 brief’s seven
items turned out to be already-decided, and one of them for the third time. They are recorded
here rather than silently dropped, because a proposal that is refused without a written reason comes
back.
F-R39-01 — The Fog-of-War compute-shader migration is refused, and ADR-156 already refused it twice
The brief asked to “migrate the heavy CPU-side Visibility Polygon generation to WebGPU Compute
Shaders via Raymarching”, while preserving Q-010 server authority.
There are two independent reasons this cannot be done, and the first is the awkward one.
-
There is no subject to migrate. The client has never computed visibility. Four mechanisms agree and ADR-156 already enumerated them: §9.2 puts LOS and FOW production out of scope for the client’s WASM crate; the wire unconditionally sends
DISCLOSURE_PARTIAL;mask.ts’s type records that there is “no way to construct it from geometry”; andfog.tsrecords that it “does not decide anything”. The Geometry Worker computes pathfinding, not raycasting. So the “heavy CPU-side generation” the brief proposes to move does not exist in this codebase. -
Raymarching visibility on the client is prohibited. ADR-017, restated in
Descent.Geometry/src/lib.rs, names and excludes “no authoritative WGSL implementation”.frontend-guidelines.md§2.3 lists “a client-side Fog-of-War implementation” under Never propose these. ADR-034 owns the rule that the client may not produce visibility, and ADR-156Depends-on 034precisely because that rule is what makes a GPU workload here permissible at all.
ADR-156 is the owner and it is Accepted. Its title is the disposition: “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.” This round’s brief is the third such
proposal. No new ADR is minted, deliberately: re-deciding a decided question would put two ADRs
on one claim, which is the exact defect docs-and-adr-workflow.md §2 records (“two mutually
exclusive UGC isolation models … with no record of which decision was newer”).
What is legitimate was already built and is still unconsumed. ADR-156’s own “Not done” list
says WEBGPU_BACKEND_BUILT is false, featherPlan has no caller, and the WGSL is deliberately not
copied into src/. core/descent-fog-compute holds one parity-tested distance-transform shader
that turns the authoritative mask’s 5 Hz jagged edge into a soft ramp — which is exactly the visual
outcome the brief asks for (“soft shadows… based on the authoritative server state”), reached
without the client deciding anything. Building that consumer is the real work this item names, and
this round did not do it.
F-R39-02 — QuickJS was declined by ADR-144 before this round proposed it
See ADR-165 decision 3. QuickJS is C and needs a libc wasm32-unknown-unknown does not have, so
adopting it costs the zero-import invariant that is ADR-144’s entire prize. JS_SetInterruptHandler
additionally has no object here, because the guest is a JavaScript engine already (Boa), and the
gas meter reaches a hostile script through the interpreter that runs it — measured, not argued.
F-R39-03 — The 144 Hz interpolation item was already built, and its central defect already fixed
The brief asked to “implement strict Client-Side Interpolation (Lerp) to smoothly animate entities
between 20Hz server states at high refresh rates”. src/scene/interpolation.ts does this, and its
header already carries the argument the brief’s framing would have got wrong:
position += delta * 0.25 per frame converges four times faster at 144 Hz than at 30 Hz, so the
same server stream renders as different motion on different hardware. The module is expressed as a
half-life against elapsed time for that reason, which also makes overshoot impossible.
shortestTurnDelta covers the rotational wraparound case beside it.
What the item genuinely still owes is not the lerp. It is that nothing decouples the render loop
from the tick cadence — there is no requestAnimationFrame loop reading a sequence-numbered
double buffer — and ADR-151 already describes the frame boundary that would drive it.
Phase 2 — what was not done
P7 and CLAUDE.md §6 rule 5. The Phase 2 brief carried seven items. Three were already decided
(F-R39-01…03 above), one was spiked and documented, and three were not built at all. Listing them
here rather than leaving the gap to be discovered is the whole of rule 5.
Built and proven:
- Spike S7, both halves.
tools/wasm-gas, its two Rust suites, the V8 suite and the Boa suite. ADR-165 records the decision.
Decided and NOT built — the pivot ADR-165 licenses but does not perform:
- The single-worker supervisor does not exist.
plugin.worker.tsstill spawns one runtime worker per plugin andsupervisor.tsstill usesWorker.terminate()as the per-plugin remedy. What ADR-165 establishes is that the pivot is admissible; performing it means rewritingWorkerRuntimeHostto hold N instances on one thread, givingPluginBudgetsa gas field, and installing the budget in#runOnebefore eachdescent_invoke. None of that is written. - Nothing instruments a plugin on the load path.
compilePayloadfetches and compiles; it does not rewrite. The rewriter is a CLI a build script calls, and the “instrument or refuse” rule ADR-165 states is a rule with no enforcement point in the client yet — which byP4means it is currently a description, not a requirement, and ADR-165’s Enforcement section lists only the suites that prove the mechanism rather than a check that it is used.
Not started:
- Profile C / the mobile companion app (brief item 1). Nothing was written. The design
constraint worth recording for whoever picks it up: §13 requires a degradation to be a total
function of the capability record, and form factor is not currently in that record — so this
needs either a new
CapabilityName(which obliges a Guardrail 7 row, orguardrail7-matrix.test.tsfails) or a separate frozen device-class value resolved at the same injection point inindex.tsx. Guardrail 7 already carries two blank rows whose text names a Profile C path that does not exist (webcodecs,worker-font-rasterisation). - The AudioWorklet consumer (brief item 3, Track 1).
core/descent-audio-dspstill has no caller.denoise-policy.ts(ADR-157) already decides which backend a session gets and is unconsumed; what is missing is theAudioWorkletProcessorthat loads the module and the 128↔480-sample reframing its ABI describes. The 2.67 ms budget the brief names is already the crate’s own recorded figure. - The Discord Activity path (brief item 3, Track 2). Not started. The brief’s zero-trust
ruling is right and should be kept when it is: a Discord identity must not mint a VTT session,
and the VTT login screen must still appear inside the iframe. Two things to check first, because
both could invalidate the approach before any code is written — whether an embedded Activity can
satisfy §5’s COOP/COEP requirement at all, and whether
@discord/embedded-app-sdkviolatesfrontend-guidelines.md§10’s rule that the game route’s bundle graph carries no third-party origin. - Cross-browser Playwright (brief item 6). Not configured. The obstacle is recorded in
playwright.config.ts’s own header and is not incidental: screenshot baselines are platform- and engine-specific, and baselines for WebKit and Firefox cannot be generated from this machine. The honest configuration is therefore not “add two projects” — it is to add them for the state-asserting specs and leave the visual specs on Chromium, because that config’s own measured argument is that a screenshot is the wrong instrument for “is the thing there”. - The fog feather consumer (the legitimate half of brief item 5, per F-R39-01). Still exactly
where ADR-156 left it:
WEBGPU_BACKEND_BUILTisfalse,featherPlanhas no caller, and the WGSL is deliberately not copied intosrc/.
A pre-existing red assertion was found and fixed rather than reported: main carried a failing
docs-publication-manifest.test.mjs because the R39 decision record merged without its manifest
row. See the commit; it is unrelated to this brief and is noted because it means the suite was not
green before this work started either.
ADR-166 — The Companion App is an absent renderer, not a hidden one; a Discord Activity moves voice and never identity; and form factor is not a capability
Status: Accepted Date: 2026-08-12
Context
R39 Phase 2 (Part 2) asked for three things: a mobile Companion App surface with a manual toggle to the tactical map, a dual-track voice strategy in which a Discord Activity disables this client’s own WebRTC while still requiring a VTT login, and cross-browser E2E coverage.
Two owner rulings arrived with the brief and both narrowed the design in useful ways. Form
factor is a UI presentation state, not a CapabilityName, and must not enter Guardrail 7. And
the Discord Activity iframe must be assumed unable to satisfy strict COOP/COEP, so the client
must degrade rather than crash when SharedArrayBuffer is absent.
Decision
1. Companion mode unmounts the canvas. It does not hide it.
The <canvas> element lives inside a <Show> keyed on the presentation mode. Leaving it mounted
under display: none would look identical in a screenshot and would keep a WebGL context, a
Babylon Engine, a requestAnimationFrame loop and a texture working set alive — on a phone,
which is the device the mode exists for. §9.6.2 already records the shape of that mistake: a 4K
texture held in the V8 heap is “a renderer-process crash rather than a slow frame”.
This forced one structural change, and getting it wrong cost thirteen existing specs before it was
right. Tabletop construction was an onMount on App, which fires once per component and is
therefore wrong once the canvas comes and goes.
The first attempt used the element’s ref callback, and it is worth recording why that fails,
because it fails convincingly. A SolidJS ref fires when the element is created, not when it
is inserted. createTabletop hands the element straight to Babylon, which builds an Engine on
it and attaches its pointer observables — and doing that to a detached node produces a scene that
renders correctly and whose picks never resolve. The table looked right; every pointer gesture
landed on a canvas Babylon had measured at zero size, and token-drag.spec.ts failed with an
empty lease-request array rather than with anything naming a canvas.
The shape that works is a component: TacticalCanvas owns the element, and its onMount
fires after insertion — the guarantee the old code had for free by living inside App’s own
onMount, and the one the <Show> silently removed. Its onCleanup is the matching half and is
what makes companion mode an absent renderer rather than a leaked one. Without the teardown the
Engine would outlive its canvas — a GL context and a render loop drawing into a detached
element for the rest of a four-to-eight-hour session, which is worse than never unmounting at all.
Pairing the teardown with the element rather than with an effect on the mode signal is also
deliberate. An effect would have to be right about ordering relative to the <Show>’s DOM
removal; onCleanup is defined to run when the owner is disposed, so there is no ordering to be
right about.
One layout consequence, found the same way: the toggle back to the Companion App was a floating
button at bottom-4 left-4, which is inside SessionStatus’s bottom-3 left-3 w-72 panel.
Playwright reported “subtree intercepts pointer events” and the click never landed. Every corner
of this canvas is claimed — session status bottom-left, plugin status bottom-right, the side panel
down the right edge, the tool rail at mid-left — so the control moved into TopBar, which is
z-30, is the only surface nothing overlays, and is where top-level navigation belongs anyway.
The enforcement point is e2e/companion-mode.spec.ts, and its load-bearing line is
expect(page.locator('canvas')).toHaveCount(0). toBeHidden() would pass on the defect this
decision exists to prevent; toHaveCount(0) is the only assertion that distinguishes them.
2. Form factor is a frozen value beside the capability record, and not in it.
device-class.ts resolves 'handheld' | 'desktop' once, in index.tsx, next to
createCapabilities, and hands it down as a prop with no setter — ADR-064’s discipline exactly.
What it does not do is join CAPABILITY_NAMES.
The argument, beyond the ruling: every entry in that list is something the platform can or cannot
do, and every entry obliges a Guardrail 7 cell describing a degradation with defined
user-visible behaviour. A phone is not a degradation — it runs the same WebGPU, OPFS and workers a
laptop does — so the cell would have to answer “what is the defined behaviour when the capability
handheld is absent”, a question with no meaning. Filing it there would also put a presentation
preference into the matrix that decides what the client refuses to do.
Two properties of the heuristic are load-bearing and were chosen against plausible alternatives. It requires both a coarse primary pointer and a small viewport: a width-only rule reclassifies a dragged-narrow desktop window and throws away its 3D scene, and a pointer-only rule captures every touchscreen laptop. And it measures the shorter viewport side, because the value is frozen at boot and a width-based answer would depend on which way up the phone happened to be when the page loaded — a difference no player could ever connect to what they saw.
The class decides a default and nothing else. The toggle is shown on every device, including desktops, because a player who wants the sheet-and-dice surface on a second monitor is a supported case and a control that appeared only under a heuristic would be missing on exactly the device the heuristic guessed wrong about.
3. A Discord Activity moves voice, and never identity.
voice-plan.ts answers the question above ADR-157’s: is this client carrying voice at all?
Inside an Activity it is not — transport: 'discord-native', capturesMicrophone: false, and
denoise: null. Capturing here would take the same microphone twice, spatialise one copy and not
the other, double the upstream, and set §7.1’s PannerNode against Discord’s own mixer. It also
solves the ICE failure that motivates the path: a corporate firewall blocking our TURN relay is
not blocking Discord.
denoise: null rather than backend: 'none' is deliberate. 'none' means “we are capturing and
nothing is suppressing noise”, which ADR-157 writes the player a sentence about; null means
“we are not capturing”, where that sentence would be actively wrong.
Identity does not move, and discordGrantsVttSession() is a named, tested false rather than
an absence. The shortcut it refuses is the single most likely future regression in this
codebase: it is one SDK call, it removes a login screen, and every other Activity on the platform
does it. The argument, recorded so it does not have to be re-won: a Discord OAuth grant
authenticates the Discord account, and Discord account compromise is common and invisible to
us — token theft via a malicious client mod, infostealer exfiltration, ordinary phishing — each
yielding a working Discord identity with no password involved. If that minted a VTT session, every
one of them would become full control of the victim’s campaigns, purchased content and
payment-adjacent Marketplace surfaces, and the VTT would see a legitimate login. The blast radius
is not symmetric: losing a Discord account should cost a player their Discord account.
4. The COOP/COEP ruling required NO new code, and writing the obvious check would have been the defect.
This is the finding of the round. typeof SharedArrayBuffer === 'undefined' is three mistakes at
once here: it is an ESLint error outside capabilities.ts (§13’s ban list names
SharedArrayBuffer explicitly); it is a second answer to a question the frozen record already
holds, so a session could report one thing to telemetry and behave as another; and it duplicates
crossOriginIsolation, which capabilities.ts already resolves as
crossOriginIsolated === true && SharedArrayBuffer !== undefined — “both halves, because either
alone is useless”, in that file’s own words.
So an Activity degrades through the path ADR-096 already describes: presentationChannel returns
post-message instead of shared-array-buffer and the frame-skew budget relaxes by one frame.
Nothing else in the client is gated on isolation, because ADR-096 records that isolation
“gates no capability”. And ADR-157 independently records that an AudioWorklet denoiser never
needed SAB — the audio never crosses a thread boundary — so the worklet still runs in an Activity;
isolation decides only how its readings reach the main thread. voice-plan.test.ts asserts exactly
that.
5. Cross-browser coverage is behavioural, and the visual suite stays on Chromium.
Firefox and WebKit projects run the state-asserting specs; visual.spec.ts is excluded from both
by testIgnore. playwright.config.ts already argued this against itself: a baseline is platform-
and engine-specific, --update-snapshots “writes whatever it renders and calls it correct”,
and the committed tolerances already fail to catch a 365-pixel token vanishing. Adding two engines
to the visual suite would mean committing baselines nobody had looked at, on engines whose
antialiasing differs more from Chromium’s than the threshold that already misses a missing token.
testIgnore rather than a test.skip() inside the spec, because a skip reports as a run and
prints green — the shape visual-baseline-policy.ts exists to refuse.
e2e/gas-sandbox.spec.ts is the genuinely new coverage: ADR-165 proved the gas meter on V8, which
is one of three WebAssembly implementations this product ships against. SpiderMonkey and
JavaScriptCore are separate implementations, and two properties the single-worker pivot rests on
were unverified there — that an exported mutable global is writable from JavaScript as a
BigInt, and that a trap is a catchable RuntimeError rather than a realm teardown. Both are
specified; specified is not implemented, and being wrong would leave a hostile plugin uncontained
on a third of the fleet.
Alternatives Considered and Why Rejected
display: noneon the canvas. Rejected — see decision 1. It is the cheap implementation and it costs exactly what the mode exists to save.- A
tabletdevice class. Rejected: a 13-inch iPad on a table and an 8-inch tablet in one hand are different surfaces and the available input cannot tell them apart. A third value would be invented at the boundary and then consumed as though measured (P9). Tablets sit on the size threshold and the toggle settles it, which a player does in one tap and a heuristic cannot do at all. - Seamless SSO from the Discord grant. Rejected — see decision 3. This is the brief’s own ruling and the argument is recorded because the shortcut will be re-proposed.
- A
typeof SharedArrayBuffercheck. Rejected — see decision 4. - Adding
handheldtoCAPABILITY_NAMES. Rejected — see decision 2. - Running the visual suite on three engines. Rejected — see decision 5.
- Bundling
@discord/embedded-app-sdkstatically. Rejected: 1.85 MB unpacked across 8 transitive dependencies, for a path that is inert in every non-Discord session. It is reached through a dynamicimport()inside the Activity branch, which is the shapee2e-harness.tspaid to learn — “a static flag is not a tree-shake; elimination is a property of the call site”.
Consequences (including negative)
- The Companion App renders placeholders. Sheets, dice and chat do not exist as components;
what is proven is the routing. The panel says so rather than mocking them convincingly (
P9). Profile Cin ADR-005’s sense is still not built. That is a renderer — baked top-down tiles, orthographic projection, no 3D scene to shade. What this ADR builds is the surface that decides whether a renderer is constructed at all. Conflating them would let a reader believe the tactical projection exists.- Nothing checks that Firefox and WebKit draw the table correctly. Stated plainly because the cross-browser projects make it look otherwise.
- The AudioWorklet has no live consumer.
denoise.worklet.ts,denoise-abi.tsanddenoise-budget.tsexist and are tested, andApp.tsxreports availability asabsentbecause nothing in this tree opens anAudioContextyet. Claiming otherwise would make the session log say RNNoise is running when no worklet has been constructed. - A device-class misdetection costs one tap, by design. That is the whole reason the class decides a default rather than a capability.
- WebAuthn inside a cross-origin iframe is an open question. A passkey in an Activity needs a
publickey-credentials-getpermissions-policy delegation from Discord. If it is unavailable the fallback is a password, not a Discord token — the ruling does not bend, the factor does.
Enforcement (ADR-045)
e2e/companion-mode.spec.ts— 7 specs on three engines, including thetoHaveCount(0)assertion and a three-cycle round trip that catches a leak one pass would miss.e2e/gas-sandbox.spec.ts— 5 specs on three engines, including ADR-165’s co-tenancy claim.src/platform/device-class.test.ts,src/platform/discord-activity.test.ts,src/audio/voice-plan.test.ts,src/audio/denoise-budget.test.ts,src/audio/denoise-abi.test.ts— 52 T1 assertions, counted by running them rather than by adding up estimates, after this line first said 48.src/audio/denoise-abi.test.tsis the drift guardgas-abi.test.tsandguest-abi.test.tsalready are for their contracts. It was written because this ADR’s own source file claimed it existed before it did —denoise-abi.ts’s header said a test readabi.rsand failed on divergence, which was a description of an intention. The failure it now prevents lands inside anAudioWorkletGlobalScope, where nothing is watching and the only evidence is a message posted to a port with no consumer yet.frontend-ci.ymlruns all of it, and gained three browsers with this round: installing only Chromium would now fail the job, because Playwright reports an uninstalled engine at the first test rather than at setup.
Two things running three engines actually found
Recorded here rather than in a commit message, because the second one bears on whether this round’s subject — the mobile surface — is deliverable at all.
F-R39-04 — WebKit cannot load this client’s workers under the dev server. Adding WebKit and
Firefox to the whole suite first, rather than to a chosen subset, is what surfaced it: 26 of 123
specs failed, and every WebKit failure that touches fixtures.ts’s strict page-error assertion
reports Importing a module script failed. This client constructs six workers with
{ type: 'module' } — network, render, geometry, plugin supervisor, plugin runtime. The specs
that touch no worker pass, including both of this round’s.
The scope is narrower than the sentence sounds, and the narrowing is load-bearing. This is
measured against pnpm run dev, which serves unbundled ESM to each worker; a production build
bundles a worker into a single module, so it may not reproduce there. That is untested. What
is established: the dev configuration does not work in WebKit. What is not established: that
Safari cannot run a shipped build.
Why it matters more than a normal engine gap: iOS Safari is the only browser engine permitted on iPhone, and Profile C’s whole premise is that mobile players are downgraded gracefully rather than abandoned. If this reproduces on a production build, the Companion App has no engine on the device class it was designed for. It is an open item and it is the highest-value one this round produced.
F-R39-05 — Firefox and Chromium disagree about a pointer gesture, deterministically.
token elevation › a second lift measures from where the first left the token expects
unitsToRaw(9) and Firefox produces unitsToRaw(11) from the identical drag. The elevation specs
derive whole-cell steps from pixel deltas, and that derivation is calibrated to Chromium’s input
model. §14.9 forbids retrying a flake; this is not one — it reproduces — so it is a finding. It
does not indicate a defect in the elevation logic so much as an untested assumption that a drag of
n pixels means the same thing on every engine.
Both are why the Firefox and WebKit projects now name their specs with testMatch rather than
subtracting the visual suite with testIgnore: a new spec is Chromium-only until someone
deliberately adds it, which is the safe default while two of three engines have known gaps.
Open items, stated rather than left to be found
- The adapter exists and its handshake is untested against a real Discord client.
discord-sdk.tsperforms the dynamicimport(), constructsDiscordSDK, awaitsready()and returns the three place-identifying ids. What no test here can cover is the handshake itself: it needs a real application id and a real Discord client hosting the frame. The failure path is covered — a build with noVITE_DISCORD_APPLICATION_IDrefuses and says so, which is the state every fork and preview deployment is in. The code-splitting claim is measured rather than asserted: main chunk 1 200 201 bytes with the SDK in a separate lazily loaded 147 730-byte chunk, andSPEAKING_START— an RPC event name only the package contains — absent from main and present in the chunk. The obvious anchors are both wrong and are recorded in the workflow:DiscordSDKmatches this client’s own destructure anddiscordsaysmatchesdiscord-activity.ts’s host constant, so both are present in main and neither means the SDK shipped. - Nothing authenticates. The VTT login screen is not built, so “an Activity still presents the standard login” is a decision about a screen that does not exist yet. The decision binds whenever it is built; it is not satisfied today by anything.
- No
AudioContextis opened anywhere, so the worklet has never run in a browser. - WebAuthn-in-iframe, above.
§11 Index Line
| 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 (fires once per component) to the element’s ref (fires per element), paired with a disposing effect, because without teardown the Engine outlives its canvas and draws into a detached element for a 4–8 hour session. 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: Firefox and WebKit run the state specs and visual.spec.ts is testIgnored, because a baseline is engine-specific and the committed tolerance already misses a vanishing token. 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. Not done: no SDK adapter, no AudioContext, 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); interacts with 053, 061, 063, 123; introduces no Q-ID |