Skip to content

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.

QuestionOutcomeWhat decided it
Delete Descent.RngKit entirely?No — the DRBG is carved out and keptIt 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 coveredIt 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 oneClause 5 said it ends “when one implementation ends”. Neither did
Grant the trusted engine extended WASI?RefusedADR-137 clause 3 makes zero imports the first invariant, and DescribeShapeFailure enforces it structurally
Two engines, then, differing in what?Fuel, and nothing elseA trusted tier buys the removal of instruction counting. It does not buy a capability
Who decides a cartridge is official?The host, never the manifestA publisher signs their own manifest, so a self-declared tier is a self-granted privilege
Write C# to evict stale .cwasm files?RefusedA 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 Forge direction 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:

  1. It is a published wire contract. SeededDrbgProvider.AlgorithmId is DESCENT-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.

  2. It is the authority’s dice (ADR-016, ADR-017). SeededDrbgActionRandomSource binds it to IActionRandomSource, the port Application declares and deliberately ships no default for. Deleting it would leave the silo with no generator behind GameActionContext.Random, and Descent.ReplayTests depends on a stream rehydrating to the state it was written from.

  3. ADR-149 decision 8 makes it MORE load-bearing after the cutover, not less. “A ruleset cartridge draws no dice. input.rolls carries 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

  1. core/Descent.RngKit/ is deleted, not archived, along with plugins/Descent.Vtt.Plugins.BRP, plugins/Descent.Vtt.Plugins.CoC7e, their suites, AlcCartridgeLoader, CartridgeLoadContext, AlcCartridgeLoaderTests, rngkit-ci.yml, rngkit-release.yml and DescentVTT.Plugin.CoC7e.slnx. 41 349 lines removed.

  2. SeededDrbgProvider and IRandomProvider move to core/Descent.Drbg unchanged. The namespace moved; nothing below the AlgorithmId line did. A namespace is not part of the wire contract and the constant that is part of it is untouched.

  3. 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 to modules/ beside the public-NuGet SDK would have been a licensing change made in passing.

  4. A thin core/Directory.Build.props is added, because Descent.Drbg is the first .NET project under core/ that is not inside Descent.RngKit or Descent.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 lost TreatWarningsAsErrors. 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.

  5. 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 WasmCoreDrbgParityTests is still a condition of admitting the second. The suite followed the type to its new namespace and was not regenerated from either side.

  6. The Descent.RngKit.Serialization reference in Descent.Vtt.Server.csproj was already dead. Its comment claimed RngKitJsonContext served RngAuditPacket “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 — including hitPointsMaximum staying Deprecated, because AttributeAuthorisationFlowTests asserts 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.
  • AlcCartridgeLoaderTests is deleted and not re-expressed. It asserted that a cartridge’s dependencies resolve inside its own AssemblyLoadContext, that Descent.Vtt.Sdk does not, and that the context is collectible. None of the three has a wasm analogue — a .wasm module 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 SeededDrbgProvider too, as the brief said. Rejected on the three rules above. The silo would have had no generator behind IActionRandomSource, Descent.ReplayTests would 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 P8 describes becoming permanent.
  • Moving the DRBG into Descent.Vtt.Infrastructure directly. Considered, and it would have removed a project. Rejected on P8: 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.slnx is gone and Full.slnx no longer contains a plugins/ folder. CLAUDE.md §5’s list is now wrong in a way this round owns.
  • Two Directory.Packages.props hubs, not three. architecture-rules §6 states three “per ownership boundary” as an invariant; one boundary ceased to exist.
  • Descent.Vtt.Sdk’s IRulesetEngine has 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

  1. WasmCartridgeLoader replaces AlcCartridgeLoader behind the unchanged ICartridgeLoader port. Application never learns that loading changed, which is the property the port existed for. Every failure is still an archived CartridgeLease and never an exception, because ADR-020 requires the room to open either way.

  2. The signed manifest now declares what the assembly used to expose. A .wasm module exports three symbols and a byte range (ADR-147) and has no type system to enumerate, so rulesets and actions are stated in cartridge.json rather 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.

  3. 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 files is an unsigned payload selected by a signed document, which is the exact gap files exists to close.

  4. Wasmtime’s on-disk AOT cache is enabled, and no C# deletes an entry. The knobs the brief named — files-total-size-soft-limit and cleanup-interval — are keys in a TOML document that wasmtime_config_cache_config_load parses; there is no binding-level equivalent, so WasmCompilationCache writes the document and the host supplies numbers. The refusal to write eviction code is the load-bearing half: a cached .cwasm is mmapped 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.

  5. 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 single WasmSandboxOptions — a first-party cartridge is not given a bigger heap, only an unmetered one.

  6. “Extended WASI privileges” for the trusted engine is REFUSED. ADR-137 clause 3 makes zero imports the first invariant, and WasmSandboxEngine.DescribeShapeFailure refuses 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 populated Linker would convert a structural boundary into a reviewed one, which P3 orders strictly below. A trusted tier buys the removal of instruction counting. It does not buy a capability.

  7. 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 MaxExecutionTime still 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.

  8. 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. OfficialPublishers names which trusted keys are first-party, and a name there that is not in TrustedPublishers fails startup: it is either a typo or a belief that something is trusted when no key verifies it.

  9. An empty OfficialPublishers is the safe value and is not refused, unlike an empty TrustedPublishers, 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”.

  10. A cartridge whose manifest still names an entryAssembly is refused by name. EntryAssembly is 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 bindingYes
Native library carries wasmtime_config_cache_config_loadYes
Native library carries files-total-size-soft-limit, cleanup-intervalYes
The generated TOML is accepted and the engine executesYes
Trusted engine (consume_fuel = false) executes an ordinary guestYes
Trusted engine still stops a spinning guest on the epoch deadlineYes
Either engine admits an importing moduleNo

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 .cwasm directory. Refused on decision 4’s mmap argument. 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.Sandbox to serve two engines. Rejected on blast radius. Descent.Sandbox is 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 Module belongs to the Engine that 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.Execute throws, and this is the round’s largest owed item. IRulesetEngine.Execute is synchronous and RoomGrain calls 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 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).

    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. ExecuteAsync is the real surface and is complete; what is missing is the caller. RoomGrain.SubmitActionAsync must become dispatch-and-reenter — the idiom hydration, cartridge acquisition and authorisation already use — and that changes IRoomGrain’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.Schema is 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. ExecuteAsync reads a refusal and otherwise reports that it proposed nothing. The envelope and the transport are real; the vocabulary on the way back is not.

  • UntrustedFuelBudget has a default where WasmSandboxOptions.MaxFuel deliberately 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 no Q-ID and is not measured.

  • sandbox-ci.yml now 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 a DedicatedStackThreadPool reserving MaxStackSizeBytes — 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) throws OutOfMemoryException from Thread.StartInternal before 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 1 is 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’s NotSupportedException — 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 P4 requires: nothing checks that a cartridge directory’s rulesets are ids any room actually binds to, and nothing runs the loader end-to-end against a real cargo-built .wasm artefact — 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.

#MutationCaught by
1The signature is computed and its result discardedAManifestSignedByAnUntrustedKeyIsRefused
2The manifest’s urn is compared to itself rather than to the requestASignedManifestDeclaringADifferentUrnIsRefusedafter repair
3The module’s digest is not comparedAModuleEditedAfterSigningIsRefused
4The traversal guard compares the path to itselfAModuleOutsideItsOwnDirectoryIsRefused
5The file-set lookup uses the wrong keyAModuleAbsentFromTheSignedFileSetIsRefused
6Every publisher is treated as officialTheTrustTierComesFromHostConfigurationRatherThanTheManifest
7The tier flag is right and the engine is always the trusted oneas above ×1
8A .NET cartridge gets the generic refusalADotNetCartridgeIsRefusedByNameAndToldToRebuild
9An empty ruleset set is admittedACartridgeWithNoUsableRulesetIdsIsRefused…after repair
10The sweep drops a cartridge a room still holdsACartridgeIsSharedAcrossRoomsAndSweptOnlyWhenNobodyHoldsIt
11Two directories claiming one urn is last-one-winsTwoDirectoriesClaimingOneUrnIsRefused
12One engine supplied twice is acceptedOneEngineSuppliedTwiceIsRefused
13Synchronous Execute refuses quietly instead of throwingSynchronousExecutionIsRefusedAndNamesTheMailboxRule

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.

#MutationOutcome
1The async fork is never takenCaught
2The per-actor bound never refusesCaught
3Re-entry never releases the actorCaught
4A voided batch is applied regardlessCaught — after repair
5An unknown completion is not discardedCaught — after repair
6actor.missing is not distinguished from a generic refusalSURVIVED
7The cartridge is handed the LIVE actorCaught — after a new test
8The snapshot records the wrong revisionCaught

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

  1. The fork is decided by the engine’s TYPE. An engine that cannot answer in a turn implements IAsyncRulesetEngine; RoomGrain pattern-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” and Execute says “you did not”.

  2. 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.

  3. 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.

  4. 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 across await points” 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.

  5. The actor is FROZEN at dispatch, and this is the subtlest decision here. Actor is mutated in place — Revision has a private setter and ApplyMutation advances 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 reads target.Revision and sees 4; it builds a proposal whose premise is 4, which matches; and ProposeBatch accepts 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. DispatchedActorSnapshot is what makes the premise mean what it says.

  6. 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 ProposeBatch voids the whole batch on actor.revision.stale. Re-entry runs through the same ProposeBatch as 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.

  7. 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 PremiseFailed instead 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.

  8. Dispatched is a new status, not a reused Accepted. 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.

  9. The outcome crosses back as an id, not as a value. A RulesetOutcome holds MutationProposal values from Descent.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 rule OnCartridgeOpenedAsync already follows.

  10. 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] on RoomGrain. 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 Actor to 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 Dispatched followed 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.
  • ActorBusy is a status clients have not seen before. A UI that treats every non-Accepted status as an error will show it as one; it is backpressure and the correct response is to retry.
  • The room’s own Actor is still mutable, and nothing structural stops the next author handing it to a cartridge. DispatchedActorSnapshot is 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, tagged applied, refused, premise-stale, actor-gone, batch-refused, orphaned. This is ADR-164’s P4 enforcement point: a dispatched action that ends in any way but applied produces 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. orphaned and a rising premise-stale are the two worth alerting on.
  • Not enforced, and stated as P4 requires: the actor-gone tag 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 into batch-refused survives, 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:

ShapeBytes per operation
sealed class (shipped)56.0
readonly record struct56.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.
  • UntrustedFuelBudget is unmeasured and carries no Q-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 1 before touching MaxStackSizeBytes, and should not touch MaxStackSizeBytes without an argument, because it is a containment ceiling.
  • The Marketplace and Studio contexts were not built or run. ADR-M-001 and 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.md and the rule files carry stale statements about plugins/, RngKit and the three props hubs. CLAUDE.md §5 and .claude-rules/architecture-rules.md §5/§6 are updated by this round; the docs/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:

tiermechanismremedycost
innergas exhaustioncatchable RuntimeError, attributed, co-tenants unaffecteda rewrite pass we must trust
outerWorker.terminate()uncatchable kill of the shared workerevery 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:

claimreading
rewrite of the real 3.03 MB Boa payload157 ms, 12 955 charge points
imports after metering0 (unchanged)
module validity after meteringvalid; every section preserved
benign script output, metered vs unmeteredbyte-identical
hostile while (true) {}traps, RuntimeError: unreachable, budget exactly spent
host after a trapruns the next script to completion, repeatedly
same script at 200 000 / 200 000 000 unitstraps / 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 on P3 — 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.add while taking longer. ADR-159 draws the same distinction on the silo side.
  • Memory is not bounded by this. memory.grow is 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 maxRecursionDepth stops it first and returns a clean RuntimeLimit refusal 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 — reads tools/wasm-gas/src/lib.rs and 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/**:

  1. Gas rewriter (clippy + both corpora)cargo clippy --all-targets --all-features -- -D warnings and cargo test, because a local clippy exits 0 on lints this pipeline rejects.
  2. Build the gas fixtures — after Build, since the Boa half needs descent_wasm_core.wasm.
  3. 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.

  1. a_loop_is_charged_per_iteration_and_not_once_on_entry asserted nothing of the sort. Moving the charge from after the loop header 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.
  2. 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.

  1. 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”; and fog.ts records 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.

  2. 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-156 Depends-on 034 precisely 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.ts still spawns one runtime worker per plugin and supervisor.ts still uses Worker.terminate() as the per-plugin remedy. What ADR-165 establishes is that the pivot is admissible; performing it means rewriting WorkerRuntimeHost to hold N instances on one thread, giving PluginBudgets a gas field, and installing the budget in #runOne before each descent_invoke. None of that is written.
  • Nothing instruments a plugin on the load path. compilePayload fetches 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 by P4 means 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, or guardrail7-matrix.test.ts fails) or a separate frozen device-class value resolved at the same injection point in index.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-dsp still has no caller. denoise-policy.ts (ADR-157) already decides which backend a session gets and is unconsumed; what is missing is the AudioWorkletProcessor that 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-sdk violates frontend-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_BUILT is false, featherPlan has no caller, and the WGSL is deliberately not copied into src/.

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: none on the canvas. Rejected — see decision 1. It is the cheap implementation and it costs exactly what the mode exists to save.
  • A tablet device 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 SharedArrayBuffer check. Rejected — see decision 4.
  • Adding handheld to CAPABILITY_NAMES. Rejected — see decision 2.
  • Running the visual suite on three engines. Rejected — see decision 5.
  • Bundling @discord/embedded-app-sdk statically. 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 dynamic import() inside the Activity branch, which is the shape e2e-harness.ts paid 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 C in 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.ts and denoise-budget.ts exist and are tested, and App.tsx reports availability as absent because nothing in this tree opens an AudioContext yet. 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-get permissions-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 the toHaveCount(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.ts52 T1 assertions, counted by running them rather than by adding up estimates, after this line first said 48.
  • src/audio/denoise-abi.test.ts is the drift guard gas-abi.test.ts and guest-abi.test.ts already are for their contracts. It was written because this ADR’s own source file claimed it existed before it diddenoise-abi.ts’s header said a test read abi.rs and failed on divergence, which was a description of an intention. The failure it now prevents lands inside an AudioWorkletGlobalScope, where nothing is watching and the only evidence is a message posted to a port with no consumer yet.
  • frontend-ci.yml runs 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.ts performs the dynamic import(), constructs DiscordSDK, awaits ready() 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 no VITE_DISCORD_APPLICATION_ID refuses 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, and SPEAKING_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: DiscordSDK matches this client’s own destructure and discordsays matches discord-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 AudioContext is 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 |