Skip to content

Architecture Decision Rulings — R36

Architecture Decision Rulings — R36

Date: 2026-08-10 Scope: the frontend UGC sandbox and the Ruleset Forge foundation — ADR-040’s plugin worker, the guest ABI it speaks, the envelope contract behind it, and the cartridge build pipeline.

Five decisions: ADR-144 (the client plugin worker speaks the same three-name guest ABI; Extism is declined), ADR-145 (the guest envelope is one JSON Schema with generated Rust, C# and TypeScript bindings), ADR-146 (Spike S6 is answered: the browser has no in-guest interrupt, so preemption is Worker.terminate() and a runtime is one worker per plugin with a fresh instance per invocation — amends ADR-040), ADR-147 (a cartridge is a core wasm32 module with zero imports, built and gated by the Forge pipeline; componentize-dotnet and .wit are declined with a trigger), ADR-148 (the Ruleset Forge is a fourth Studio workspace — amends ADR-109).

Two of the five say “the same as the silo”, and that is the theme rather than a coincidence. The Mega-Epic Audit’s §7.4 asked for one thing above all — “design its guest ABI as the same three names, so the second tier is additive rather than a parallel system. This costs nothing now and is expensive to retrofit” — and ADR-144 and ADR-147 are that instruction applied at the two ends of the platform’s UGC surface.

Cited by name rather than linked. ADR-113 clause 3 publishes docs/decisions/ entire and leaves everything else unpublished by default, so a markdown link from here to docs/audits/MegaEpic_Backend_Modernization_Audit.md renders as a 404 and rehype-markdown-link-resolver refuses it at build time. R35 took the same route for the same reason and this follows it.

QuestionOutcomeWhat decided it
Which ABI does the client plugin worker speak?The silo’s, unchangedOne artefact that runs in both hosts is worth more than an import surface the browser could have afforded
Extism for the frontend tier?Declined, with a triggerNot ADR-137’s import argument, which does not transfer — the second-plugin-ABI argument, which does
How is envelope drift prevented without .wit?One JSON Schema, three generated bindingsThe shape was written out four times and nothing made the four agree
Can ADR-040’s interrupt-driven preemption be built?No, and something better is availableA synchronous descent_invoke cannot be interrupted in a browser; terminate() kills a spinning guest in 2.19 ms
One runtime per plugin, held across frames?One worker per plugin, fresh instance per invocationA held instance leaks 40.5 KiB per invocation — the ABI has no free, by design
componentize-dotnet for cartridges?Declined, with a triggerAudit §6.1’s three compounding reasons, unchanged; a 336-byte dependency-free cartridge is the counter-demonstration

0. What was measured, and on what

Every figure below was produced on 2026-08-10, on win-x64, against descent_wasm_core.wasm at 3 180 219 bytes, on two engines:

EngineVersion
Node24.18.1 (V8 13.6.233.17)
ChromiumPlaywright 1.62.1’s build, headless

Reproduction: node tools/plugin-frame-budget-probe.mjs [--browser]. The probe is retained, following R35’s precedent and for its reason — ADR-137 recorded its own probe as “not retained” and the audit would otherwise have been the only evidence for its own claims.

Chromium’s performance.now() is coarsened to 100 µs as a Spectre mitigation, so every browser figure below is a multiple of 0.1 ms. That is why the two engines’ columns are reported separately rather than averaged: the browser’s resolution is worse than the quantity being measured in the sub-millisecond arms, and hiding that behind a mean would make the numbers look more precise than they are.

One figure in an earlier draft of this document was wrong for exactly that reason, and the correction is recorded rather than quietly applied. The guest-to-native ratio in ADR-146 was first taken with the native arm timing a single sweep — about a third of a millisecond, which is three clock ticks — and it read 1 518×. A second run of unchanged code read 930×. The guest arm was stable across both; the instability was entirely the denominator. The probe now times a batch of 50 and divides, and three consecutive Chromium runs then read 1 300×, 1 329× and 1 305×. The ratios below are the corrected ones. The lesson is not about this number: a quantity measured in three ticks of the clock reading it is not measured, and a four-significant-figure ratio taken off one is an invented number with a real measurement standing behind it.

No Q-ID is minted here, on the same reasoning R35 gives: these are readings, not budgets. P5 reserves the registry for a figure that has been profiled against a target, and none of these has been. ADR-040’s own budgets are host-supplied runtime configuration by clause 1 and §3.1, and this work does not change that — PluginBudgets has no defaults anywhere and refuses to construct without a complete set.


ADR-144 — The client plugin worker speaks the silo’s guest ABI, and Extism is declined

Status: Accepted Date: 2026-08-10 Depends-on 137 (the three names and the empty-import invariant are ADR-137’s); Amends 040 (clause 1’s “QuickJS runtime” becomes a wasm32 guest on the shared ABI); interacts with 010, 017

Context

ADR-040 and whitepaper §9.5 Guardrail 5 specify a client PluginWorker running QuickJS. The Mega-Epic Audit §7.1 found that it does not existapps/vtt-frontend-client/src had a geometry worker, a network worker and a render worker and no plugin worker — which turned the question from “should we upgrade it” into “what should it be”, with nothing built to constrain the answer.

Two candidate ABIs were available. The platform already has one: the three names in WasmGuestAbimemory, descent_alloc, descent_invoke — which the silo’s WebAssembly engine speaks and descent-wasm-core satisfies. Extism supplies another, with better ergonomics and a plugin manifest format the platform would otherwise build itself.

ADR-137’s stated objection to Extism does not transfer to the browser, and pretending otherwise would be the easy mistake. It rejected Extism because “its plugin model reaches the host through imported functions, which is precisely the edge clause 3 removes”. The client worker has no zero-import invariant to protect — ADR-040’s design is built on a host edge, with per-frame snapshots in and mutations out through the CQRS command queue. An import surface is the architecture there.

Decision

  1. The client plugin guest ABI is the same three names, byte for byte. guest-abi.ts declares them and guest-abi.test.ts reads WasmGuestAbi.cs and fails if the two ever disagree.
  2. A plugin module that declares any import is refused before instantiation, by the same shape check the silo runs. This is a choice on the client, not an inheritance, and the reason is decision 3.
  3. One artefact runs in both hosts. descent_wasm_core.wasm — built once, for wasm32-unknown-unknown — is instantiated by wasmtime in the silo and by WebAssembly.Instance in the browser worker, with no second build, no second binding and no conditional code on either side. That is what the empty import list buys and it is the whole argument for keeping it where it was not required.
  4. The envelope gains an input field (ADR-145), carrying ADR-040 clause 4’s per-frame snapshot as a pre-serialised fragment handed to the guest’s own JSON.parse. Additive by the envelope’s own version rule, so v does not move.
  5. Extism is declined, on the second-ABI argument rather than the import argument. tech-stack-currency.md §5 question 1 refuses a second definition of something already defined, and adopting Extism would give the platform two plugin ABIs, one per tier, at exactly the moment the cartridge and plugin surfaces are converging on one.
  6. The polyglot tier is additive and needs nothing further. A plugin written in Rust and compiled to core wasm32 exports the same three names and runs on this host with no second code path. tools/forge/templates/cartridge is a working 336-byte example.

The measurement that made this cheap rather than merely tidy

The audit’s §7.3 was the only figure bearing on this and it was taken on the wrong engine — wasmtime with cranelift, in a .NET process. Retaken on the engines that will run it:

Node (V8)Chromiumwasmtime, for comparison
Compile the 3.03 MB payload (once)4.21 ms3.80 ms422 ms
Instantiate (per invocation)0.309 ms0.200 ms
A 3d6+2 macro, fresh instance1.115 ms0.900 ms1.511 ms
— as a share of a 60 Hz frame6.7%5.4%

The cold-start objection does not exist in a browser. §2.7 of the audit calls 480 ms “the strongest single objection to this design” and answers it with a build-time precompile step. V8 compiles the same module in under 5 ms, because it tiers with Liftoff and compiles lazily. Module.Serialize has no browser counterpart and needs none — which also means audit §6.3’s rule (“ship the .wasm, never the precompiled module”) costs the client nothing at all.

Alternatives Considered and Why Rejected

  • Extism. Rejected above. Re-evaluation trigger, stated so this is a decision rather than a preference: if the platform ever wants a plugin manifest format and a plugin registry it does not own, Extism supplies both and this repository would otherwise build them. That is a product decision about distribution, not a runtime one, and it should be taken on those terms.
  • A browser-native JavaScript sandbox — a plain Worker with a scrubbed global. This is the fast option by three orders of magnitude (see ADR-146’s sweep figures) and it is rejected on containment: a worker’s global scope still has fetch, WebSocket, indexedDB and importScripts, so the isolation would be a deletion list. ADR-010’s own rule that “Shadow DOM is not a security boundary” is the same shape of claim, and the Jint path is the worked example of what a deletion list costs — several hundred lines of deep-freezing, four severed function constructors, and a scrub-and-audit pass afterwards. The cost of the alternative is stated rather than dismissed, and it is large; see ADR-146’s consequences for where that leaves the JavaScript tier.
  • A second, browser-shaped ABI with imports. Rejected as decision 3’s inverse. It is what the audit warns costs nothing now and is expensive to retrofit, and the artefact-sharing property is the concrete thing that would be lost.

Consequences (including negative)

  • A client plugin cannot ask the host anything mid-execution, exactly as a silo payload cannot. Everything it needs arrives in input and everything it wants leaves in its answer. For ADR-040’s design this is not a loss — the snapshot-in, commands-out shape is what clause 4 already specified — but it forecloses a plugin that wants to query during a frame.
  • A plugin’s state cannot live in a variable between frames (ADR-146’s fresh instance). It round-trips through the envelope, which is more work for an author and is also what makes an invocation replayable from its envelope alone.
  • The client inherits the payload’s size. 3.03 MB is a real download and it is the JavaScript tier’s entry cost. It is compiled once per session in under 5 ms, so the cost is bandwidth rather than latency.
  • A plugin’s dice are presentation, never authority. The envelope requires a DRBG key and the client’s is presentation-only: a client that could derive the server’s stream could predict a roll before it was made. protocol.ts states this at the field.

Enforcement

  • The three names: guest-abi.test.ts, which parses WasmGuestAbi.cs and asserts all three plus the declaration count — an anchor that can silently match nothing reports clean forever.
  • The zero-import rule: describeShapeFailure, called before every instantiation in both the supervisor and the runtime worker, and asserted by guest-abi.test.ts against a fixture module carrying wasi_snapshot_preview1 imports.
  • The same rule at build time: tools/forge/build-cartridge.mjs (ADR-147).

§11 Index Line

ADR-144: The client plugin worker speaks the SAME three-name guest ABI as the silo — memory, descent_alloc, descent_invoke — and refuses any module declaring an import, so one wasm32-unknown-unknown artefact runs under wasmtime in the silo and WebAssembly.Instance in the browser with no second build or binding; ADR-137's objection to Extism does NOT transfer to a browser that legitimately needs a host edge, and Extism is declined instead on the second-plugin-ABI argument, with a trigger if the platform ever wants a plugin manifest format and a registry it does not own; a browser-native scrubbed-Worker sandbox was rejected on containment, being a deletion list of the kind ADR-010 already refuses; the polyglot tier is additive because a Rust plugin exports the same three names; measured 2026-08-10, V8 compiles the 3.03 MB payload in under 5 ms against wasmtime's 422 ms, so ADR-137's cold-start objection has no browser counterpart; enforced by guest-abi.test.ts reading WasmGuestAbi.cs and by describeShapeFailure before every instantiation. (Amends 040; Depends-on 137.)


ADR-145 — The guest envelope is one JSON Schema, and three bindings are generated from it

Status: Accepted Date: 2026-08-10 Depends-on 137 (the envelope is what rides its ABI); interacts with 017, 112

Context

The Mega-Epic Audit §6.2 weighed .wit components against the core-module ABI and found exactly one advantage that survived scrutiny:

The honest reading: .wit’s real prize is … generated bindings on both sides. §2’s envelope is hand-written JSON on the Rust side and hand-written JSON on the C# side, and those two can drift; a .wit contract makes that a compile error. That is a genuine and serious advantage of the proposal and it is the argument that should carry it.

Its recommended disposition was to take the discipline without the component model: “write the envelope contract down in one place and generate both sides from it, even if that place is a JSON Schema rather than a .wit world.”

The drift was worse than the audit recorded, and counting it is what settles the question. The envelope’s shape was written out four times, not two: the Rust serde structs in protocol.rs, and three separate C# anonymous objects — in WasmCorePayload.Request, in MacroExecutionComparisonBenchmarks.BuildRequest, and in WasmCoreDrbgParityTests.Request. Nothing made the four agree, and two of them already carried different limit values with no way to tell deliberate from accidental. A fifth was about to be written in TypeScript for ADR-144’s worker.

Decision

  1. contracts/guest-envelope/v1.schema.json is the contract. The Rust, C# and TypeScript bindings are generated from it by tools/generate-guest-envelope.mjs and carry a DO NOT EDIT banner.
  2. The prose lives in the schema, not in the generated files. Every description and x-descent.remarks block is emitted as a doc comment in all three languages, so the load-bearing paragraphs — why unixMillis is required, why maxRecursionDepth has no default — are in the one place a reader of the contract will find them rather than in whichever binding they happened to open.
  3. The generated files are committed, which is the opposite of the wire codegen’s rule. frontend-guidelines.md §12 keeps flatc output out of the tree and regenerates it every build; that is right for output consumed by one toolchain and wrong here, because cargo build and dotnet build must not acquire a dependency on Node to compile a struct.
  4. --check is the enforcement point. It re-runs the generator and diffs, and it runs in pnpm run build as check:envelope and in sandbox-ci.yml before the .NET restore.
  5. The generator is purpose-built and refuses what it does not understand. It reads exactly the constructs this schema uses and refuse()s any other keyword by name and JSON path.
  6. intents and input stay outside the schema’s description, deliberately. Both are carried as pre-serialised fragments — RawValue in Rust, JsonElement in C#, unknown in TypeScript — because the intent shape is chosen by the ruleset author, and because a parse-and-re-emit round trip renormalises numbers: 1.0 becoming 1 changes what a ruleset receives.

Alternatives Considered and Why Rejected

  • A .wit world and componentize-dotnet. Audit §6.1’s three compounding objections stand unchanged; ADR-147 records them and the trigger.
  • A general-purpose JSON Schema code generator, off the shelf. Rejected on the failure mode rather than on size. A general generator’s characteristic failure is to emit something plausible for a construct it half-supports, and the divergence then surfaces three languages away at run time — which is the failure this whole mechanism exists to remove. A generator that stops at the schema, naming the keyword and the path, cannot do that. This is the “too brittle” escape the brief offered, taken in the opposite direction: the answer to brittleness here is a narrower tool, not a wider one.
  • Generating a conformance test instead of the types, leaving the hand-written structs in place. Rejected: it detects drift instead of preventing it, and it leaves five places to edit for one field.
  • Making the C# types public. Rejected. Descent.Sandbox is the one published package in the repository and its surface is ISandboxEngine; a payload-specific envelope does not belong in it. The three consumers get InternalsVisibleTo entries by name instead, and the csproj records that the alternative was not a narrower grant but keeping the drift.

Consequences (including negative)

  • A field is added in one place and appears in three, which is the point. A schema edit that is not regenerated fails CI rather than compiling and disagreeing at run time.
  • RequestLimits is now uint in C# rather than int, because the schema says uint32 and the Rust side says u32. Three call sites changed. This is the mechanism working: the C# side had been quietly wider than the contract.
  • Required members are required, not = null!, and the generator was corrected to emit them that way. P3’s ordering: a type that cannot express the wrong thing beats one that compiles and is wrong at run time, and an envelope missing key is not a thing a caller should be able to construct. System.Text.Json enforces the same rule inbound.
  • A generated C# file needs #nullable enable of its own. Roslyn treats any file named *.generated.cs as auto-generated and excludes it from the project’s nullable context, so a nullable annotation in one is CS8669 — a warning under dotnet build and an error under the Release build’s -warnaserror. It was found in exactly that gap, which is the argument for running the Release gate locally rather than trusting a green Debug build.
  • The generator is a new thing to maintain, and it is in tools/ with no tests of its own beyond the round trip --check performs on every build. Stated rather than glossed: its coverage is that the three committed outputs compile and their suites pass, which is weaker than a unit test of the emitter and stronger than nothing.
  • Two rules now govern generated code in this repository — “never committed” for the wire types, “committed and checked” for the envelope — and the difference is the consumer count. A future reader who applies one rule to the other’s artefact will be wrong; decision 3 is where the distinction is written down.

Enforcement

  • node tools/generate-guest-envelope.mjs --check — in pnpm run build (check:envelope) and in sandbox-ci.yml, whose path filters now include contracts/guest-envelope/** so a schema-only edit reaches the check that exists for it.
  • protocol.rs’s a_request_without_the_added_input_field_still_deserialises and an_absent_optional_is_omitted_rather_than_serialised_as_null — the two envelope properties a schema cannot state.

§11 Index Line

ADR-145: The descent-wasm-core guest envelope is written down once, in contracts/guest-envelope/v1.schema.json, and its Rust, C# and TypeScript bindings are GENERATED from it — buying the one advantage the Mega-Epic Audit §6.2 found in .wit components (generated bindings on both sides, so a contract change is a compile error) without the component model, an imported world or an AOT-clean SDK; the shape had been hand-written FOUR times, once in Rust and three times as C# anonymous objects, with a fifth about to be added in TypeScript; the generated files are committed rather than gitignored because cargo and dotnet must not depend on Node to compile a struct, and —check re-runs the generator and diffs; the generator is purpose-built and refuses any construct it does not understand by name and JSON path, because a general generator's characteristic failure is to emit something plausible whose divergence surfaces three languages away; intents and input stay schema-less pre-serialised fragments because the intent shape is the ruleset author's and a re-emit renormalises numbers; enforced by check:envelope in the frontend build and a step in sandbox-ci.yml. (Depends-on 137.)


ADR-146 — Spike S6 is answered: preemption is terminate(), a runtime is a worker, an instance is per invocation

Status: Accepted Date: 2026-08-10 Amends 040 (clause 1’s runtime placement and clause 2’s preemption mechanism; closes Spike S6); interacts with 052, 053, 096

Context

ADR-040 clause 2 specifies preemption “implemented with an interrupt handler … every plugin invocation runs under an interrupt callback that fires on expiry”, with a soft expiry that yields and reschedules and a hard expiry that aborts. It opened Spike S6 against its own mechanism: “the trigger granularity of QuickJS-in-WASM’s interrupt handler, and whether per-runtime memory ceilings are exposed through the chosen binding”, with a stated fallback of “a per-frame invocation budget + after-the-fact detection and disabling” and an instruction to “acknowledge that this is a weaker guarantee”.

S6 has been open since 2026-07-31 and ADR-040’s status has read “Accepted (preemption pending spike)” since. It is answered here, against a real guest on two real engines.

What was measured

1. There is no interrupt, and there cannot be one under ADR-144. A synchronous descent_invoke call cannot be interrupted by a browser. The mechanisms that would allow it — an imported host callback the guest polls, or SharedArrayBuffer plus Atomics.wait — both require granting the guest an import, which is exactly what ADR-144 decision 2 refuses. The interrupt half of clause 2 is not implementable and no amount of engineering makes it so.

2. terminate() is real preemption, and it is fast.

Measured
Worker.terminate() on a guest spinning in while (true) {}2.19 ms
Supervisor’s 5 ms timer latency while a child guest spins15.55 ms median
— the same measurement with no spinning child15.30 ms median

The second pair is the interesting one and the second row is why: the spinning guest is invisible to the supervisor. 15.3 ms is Windows’ timer granularity, not a cost this design imposes; the delta between a spinning child and no child at all is 0.25 ms, which is inside the noise of a measurement whose floor is 15 ms.

3. The memory objection ADR-040 rejected “one worker per plugin” on is half refuted. Clause 2’s alternatives section rejected it because “N workers each carrying QuickJS, while cheaper than N iframes, still multiplies the fixed cost”. Measured: a compiled WebAssembly.Module is structured-cloneable and the clone shares the compiled code, so N runtimes cost N linear memories, not N copies of a three-megabyte interpreter. What is not refuted is the isolate: 6 live guest workers cost 148.5 MB of RSS in Node, about 18 MB each. The cost is halved, not eliminated, and it is paid per registered plugin.

4. A held instance leaks, and the leak is the ABI working correctly.

Measured
Linear memory after 60 invocations on a reused instance30 → 68 pages
Per invocation40.5 KiB
Cost of a fresh instance0.309 ms (Node), 0.200 ms (Chromium)

Identical on both engines. There is no descent_free in WasmGuestAbi because the silo disposes the whole Store per execution, so nothing there is ever reclaimed and nothing needs to be. A client holding one instance across frames at 60 Hz would grow by about 2.4 MB per second.

5. ADR-040’s own worked example is confirmed, and then some. The ADR’s justification for the whole budget regime is “one plugin doing an accidental O(n²) scan over 500 tokens — QuickJS is an interpreter, so this is hundreds of milliseconds”. Measured, in the guest:

Node (V8)Chromium
O(n²) over 500 tokens, in the guest510.6 ms447.2 ms
The same logic, in the host engine natively (batch of 50)0.351 ms0.344 ms
Ratio1 455×1 300×

The prediction was right — about 27 frames for one plugin’s frame of work. The ratio is not the audit’s 24×, and the difference is the comparison arm: §2.6’s 24× is Boa-in-wasmtime against Jint, which is itself an interpreter. In a browser the arm is V8’s JIT, and the honest frontend figure is three orders of magnitude. Quote it as “roughly 1 300×” rather than to four figures — the two engines disagree by 12% and the number is a property of this workload on this machine, not a constant.

Decision

  1. The interrupt half of clause 2 is retired as unimplementable, not deferred.
  2. The hard ceiling is enforced by Worker.terminate(), which is stronger than the interrupt clause 2 asked for: it is a kill, not a cooperative unwind, and it cannot be caught.
  3. The soft ceiling becomes after-the-fact detection: an invocation that passes it is recorded and the plugin’s next invocation is deferred a frame. The browser cannot yield a running guest, so the pause is taken before the next call rather than during the last one. This half is weaker than clause 2 asked for and S6 anticipated exactly it.
  4. A runtime is one worker per plugin, reversing clause 2’s alternatives-section rejection on the measurement in point 3. The supervisor holds the module, the ledger, the queue and the RPC bridge, and never executes creator code.
  5. An instance is created fresh per invocation and discarded, on point 4’s measurement. This also makes the heap ceiling per-invocation rather than cumulative.
  6. The aggregate per-frame ceiling, the token bucket, the interest-set cap and the auto-disabling of repeat offenders are implemented as clause 2 and clause 5 specify, in BudgetLedger, with no defaults anywhere.
  7. Clause 3 is satisfied structurally rather than by scheduling. ADR-040 offers two routes — prioritise the bridge on the worker’s message loop, or move the bridge to another worker. Neither works if guest code runs on the supervisor’s thread, because a priority scheme cannot deschedule what has already started. Moving the guest out instead is the only arrangement in which “a stuck plugin MUST NOT freeze the GM’s combat tracker” is true, and point 2’s second measurement is the evidence that it is.

Consequences (including negative)

  • A plugin can overrun its soft ceiling once, every time. The cooldown costs it the next frame and the token bucket costs it the frame after that, but the first overrun always lands. That is the residue of having no interrupt and it is the guarantee S6 called weaker.
  • A plugin cannot hold state between frames. State round-trips through the envelope. More work for an author; also what makes an invocation replayable.
  • Memory scales with registered plugins, at roughly an isolate each. A creator with a dozen plugins installed pays for a dozen workers. There is no measurement here of a browser’s isolate cost — 18 MB is Node’s — and that gap is stated rather than extrapolated.
  • A killed invocation’s promise never settles, because nothing is left to settle it. The supervisor races the kill as its own arm for this reason; a scheduler that awaited only the invocation would hang forever on exactly the plugin the deadline exists to contain. This is the single subtlest thing in the implementation and supervisor.test.ts asserts it directly.
  • The JavaScript tier costs 1 500× the browser’s own JavaScript, and the consequence follows from the cadence argument the audit makes for the silo: the JS tier must not be on the frame path. A plugin doing per-frame work in JavaScript will hit the aggregate ceiling and be deferred, correctly and repeatedly. ADR-144’s polyglot tier — a Rust plugin, compiled, on the same ABI — is the answer for anything with a per-frame budget, and it is available now rather than as a future phase.

Enforcement

  • ADR-040’s own named enforcement point, at T1: supervisor.test.ts’s “ADR-040’s enforcement point — a plugin in an infinite loop” block asserts that a hanging guest is terminated at the hard ceiling, that the frame completes, that its runtime is respawned, that a second plugin is still served in the same frame, and that a repeat offender is disabled and named.
  • budget.test.ts for the ledger’s rules, including the token bucket’s “individually compliant on every invocation” evasion and the refusal to construct without a complete budget set.
  • What is not covered, stated: the real Worker plumbing — worker-runtime-host.ts, plugin.worker.ts and plugin-runtime.worker.ts — has no automated test. It is exercised by tools/plugin-frame-budget-probe.mjs under Node’s worker_threads and by hand; a Playwright spec asserting the same properties against a real browser Worker is owed and is recorded as OI-V-05 in testing-and-verification.md §10.

§11 Index Line

ADR-146: Spike S6 is CLOSED. ADR-040's interrupt-driven preemption is not implementable in a browser — a synchronous descent_invoke cannot be interrupted, and the mechanisms that would allow it all require granting the guest an import that ADR-144 refuses — so the hard ceiling is Worker.terminate(), measured at 2.19 ms on a spinning guest and stronger than the interrupt asked for, while the soft ceiling degrades to after-the-fact detection that defers the plugin's NEXT invocation, which is weaker and is the fallback S6 anticipated; clause 2's rejection of one-worker-per-plugin is reversed because a compiled WebAssembly.Module is structured-cloneable and the clone shares compiled code, so N runtimes cost N linear memories rather than N interpreters, though an isolate each is still paid; an instance is created FRESH per invocation because a held one leaks a measured 40.5 KiB per invocation, the ABI having no descent_free by design; the supervisor never executes creator code, which is the only arrangement in which clause 3's "a stuck plugin must not freeze the GM's combat tracker" is true and is measured as a spinning child being invisible to the supervisor's message loop; ADR-040's own O(n²)-over-500-tokens example is confirmed at roughly half a second, of the order of 1 300x the same logic in the host engine, from which it follows that the JavaScript tier must not be on the frame path and the polyglot tier is the answer for anything that is. (Amends 040.)


ADR-147 — A cartridge is a core wasm32 module with zero imports, and the component model is declined

Status: Accepted Date: 2026-08-10 Depends-on 137 (the ABI and the empty-import invariant); Depends-on 145 (the generated envelope is what replaces .wit’s binding generation); interacts with 071, 098, 111

Context

The Ruleset Forge proposal is to define cartridges as a .wit contract, generate C# interfaces with componentize-dotnet, and have creators compile to .wasm locally. The Mega-Epic Audit §6.1 gave three compounding objections and §6.2 gave the one real advantage; §6.2’s recommended disposition was “do not adopt the component model now. Do adopt the discipline.”

Decision

  1. A cartridge is a core wasm32-unknown-unknown module that declares zero imports and exports the three names. Not a component, no .wit world, no WASI.
  2. componentize-dotnet is declined, on audit §6.1’s three objections, which are recorded here because a summary of them is what a future proposer will read: a component is not a core module and BuildConfig sets WithComponentModel(false); components target WASI and import their world, where preview 1 emitted eight imports and preview 2’s worlds are more structured; and componentize-dotnet is built on NativeAOT-LLVM, so a cartridge SDK would inherit the reflection ban that Descent.Vtt.Sdk’s AttributeBagJsonConverter fails today. Re-evaluation trigger: revisit when there is a second ruleset author and the SDK is AOT-clean. §6.2’s own framing — “the cost being paid is contract drift and there is currently one author” — is the measure, and ADR-145 has removed most of that cost in the meantime.
  3. The pipeline is tools/forge/build-cartridge.mjs, with two gates:
    • a shape gate (cartridge-shape.mjs) that reads validity, imports, exports and size out of the artefact in microseconds — audit §6.3’s “cheap first gate”, the same one the ingestion path runs before spending a fuzzing budget;
    • an engine probe (--engine-probe) that runs real wasmtime with the silo’s real proposal configuration, through core/descent-wasm-core/examples/proposal_probe.rs.
  4. Only the engine probe answers the question §6.4 constraint 1 asks, and the pipeline says so. Node’s WebAssembly engine enables every proposal Chromium does, so WebAssembly.validate accepting a module says nothing about whether wasmtime would. Audit §2.4 is that exact failure: a module every modern engine accepts, refused by the silo with “zero byte expected”. A run without the probe prints a NOTE saying what it did not check.
  5. The size ceiling has no default. It is host configuration — WasmSandboxOptions.MaxModuleBytes in the silo — and a default here would become a second ceiling nobody set (ADR-100).
  6. tools/forge/templates/cartridge is the scaffold and the counter-demonstration. 336 bytes, no_std, zero dependencies, zero imports, the three names. It exists to make one thing unarguable: the contract is three exported symbols and a byte range, not a framework.

A finding this produced, which narrows an audit finding rather than contradicting it

Audit F-2 reads: No Rust wasm32 module could load under ADR-137’s proposal set — including a twelve-line one.” Measured against the template cartridge:

reference-typesmulti-valuedescent_wasm_core (std)template cartridge (no_std)
offoffREFUSED (offset 17984)OK, 0 imports
onoffOKOK
offonREFUSEDOK
ononOKOK

F-2’s cause is std, not Rust. Rust ships its precompiled std with reference-types on, and that is what call_indirect’s encoding comes from; a crate that does not link std has no such dependency. The audit’s twelve-line counter-example used format!, which is std.

This does not reopen ADR-140. descent-wasm-core needs std — Boa does — so the relaxation is still required for the payload that exists, and ADR-140’s argument (an externref can only carry a reference the host handed the guest, and clause 3 guarantees the host hands none) is unaffected. What changes is the scope of the claim: a future all-no_std cartridge policy could run under the original proposal set, and that option is now known to exist rather than assumed away.

Alternatives Considered and Why Rejected

  • .wit plus componentize-dotnet. Declined above, with a trigger.
  • A single gate, run always, including the engine probe. Rejected on iteration cost: the probe needs a cargo build of a wasmtime-linking example, which is minutes on a cold toolchain. The split is deliberate — the Forge runs both on publish, a creator iterating runs the cheap one — and decision 4’s NOTE is what stops the cheap one being mistaken for the whole answer.
  • Making the target a flag. Rejected: wasm32-wasip1 and wasm32-wasip2 both produce modules the engine refuses, so the flag’s only other values are wrong, and a flag whose alternatives are all wrong invites someone to try them.

Consequences (including negative)

  • A cartridge author hand-writes their envelope handling. ADR-145 removes the drift risk and does not remove the work; .wit would have generated a typed interface and this generates a struct. That gap is the real cost of this decision and it is the thing to re-measure when the trigger in decision 2 fires.
  • A C# cartridge is not currently possible, because the AOT-clean SDK does not exist. Cartridges are Rust, or any other language emitting core wasm32, until it does.
  • The engine probe couples the Forge to a cargo toolchain — the same coupling Descent.Geometry.Native.targets already imposes on the .NET build, so it is not new, but it is now on the creator’s machine rather than only on ours.

Enforcement

  • tools/forge/cartridge-shape.test.mjs — the gate’s rules, including the WASI refusal naming its own remedy, every missing export, and the refusal to run without a size ceiling. It also asserts the three names against WasmGuestAbi.cs, as the client’s suite does.
  • build-cartridge.mjs exits non-zero on a non-conforming artefact, so it composes as a gate.
  • The engine probe’s assertion is on the row BuildConfig actually ships — reference types on, multi-value off — and it reports all four rows, because collapsing a partial answer to pass/fail would hide the distinction ADR-140 turns on.

§11 Index Line

ADR-147: A Descent cartridge is a core wasm32-unknown-unknown module declaring ZERO imports and exporting the three ABI names — not a WebAssembly component, no .wit world, no WASI — and componentize-dotnet is declined on three compounding grounds (a component is not a core module and the engine disables the component model; components import a WASI world where preview 1 emitted eight imports; componentize-dotnet is NativeAOT-LLVM and would require an AOT-clean SDK that does not exist), with the trigger being a second ruleset author plus an AOT-clean SDK; the build pipeline is tools/forge/build-cartridge.mjs with a microsecond shape gate and an opt-in engine probe against real wasmtime, and ONLY the probe can answer whether the silo will accept a module because Node's engine enables proposals the silo disables — a run without it says so rather than implying a clean bill; the size ceiling is host configuration with no default; a 336-byte no_std zero-dependency template is the scaffold, and it narrows audit finding F-2 by loading under ALL FOUR proposal combinations, showing that F-2's cause is Rust's precompiled std rather than Rust, without reopening ADR-140 since Boa needs std. (Depends-on 137; Depends-on 145.)


ADR-148 — The Ruleset Forge is a fourth Studio workspace

Status: Accepted Date: 2026-08-10 Amends 109 (adds a fourth workspace to its three); Depends-on 147 (the workspace’s whole job is that pipeline); interacts with 098, 111

Context

ADR-109 defines Studio as a Launchpad plus three independently-installed workspaces — World Builder, Cinematic Replay Editor, Asset Manager — chosen so that “a bake must not be able to stall, or exhaust the memory of, the window a creator has unsaved map edits in”. Ruleset authoring currently has no home; §4’s code lab sits inside World Builder.

The brief’s reason for a separate Forge is memory isolation from map-making. The audit’s §6.4 is endorsement with a stronger reason, and it is worth taking the stronger one: the compile step for a cartridge is a cranelift-scale operation — 422 ms for a 3 MB module, paid by a creator on every build — plus a Rust toolchain, plus a .wasm artefact cache. None of that shares anything with a workspace whose working set is meshes and textures.

Decision

  1. Ruleset Forge is a fourth workspace under ADR-109’s model, with the same three-artefact-kind rule and the same contract-range and signature gates. It is installed on demand; a creator who only makes maps never downloads a Rust toolchain.
  2. It owns the cartridge build pipeline of ADR-147, including the engine probe. Audit §6.4 constraint 1: “Studio should run the equivalent of proposal_probe against its own output before shipping it, so the creator learns at build time rather than at publish time.”
  3. It must not ship its precompiled output. Audit §6.3 and §6.4 constraint 2: a serialised wasmtime module is tied to the wasmtime version and the target CPU that produced it, so a CDN full of CPU-and-version-pinned blobs is a fleet-wide invalidation on every runtime bump. The Forge publishes the .wasm; the precompiled form is a build output of the pipeline that pins the runtime. The client needs none of this — ADR-144 measured V8 compiling the same payload in under 5 ms — so the rule binds the silo’s deployment and nothing else.
  4. Its compute module is a child process, by ADR-109 decision 3’s second kind. A cartridge compile is unbounded in time and large in memory, which is the Asset Manager’s argument applied to a different workload.
  5. Everything it reports is advisory. ADR-098 is unchanged: Studio is an untrusted producer, every check is re-run server-side at ingestion, and a locally-green cartridge is not a publication guarantee. The Forge’s gates exist to move a creator’s discovery earlier, not to move the trust boundary.

Alternatives Considered and Why Rejected

  • A panel inside World Builder. Rejected on ADR-109’s own argument, which is about where work runs rather than about navigation: a cranelift compile and a Rust toolchain in the process holding unsaved map edits is the failure §1.5 exists to prevent, one workload over.
  • Folding it into the Asset Manager, which already holds heavy compute and signing material. Rejected because the two share no working set and no toolchain — a mesh decimator and a Rust compiler have nothing in common but their appetite — and because it would make a ruleset author install the baking toolchain, which is the download argument §1.5.2 makes in reverse.
  • Compiling in the cloud, at ingestion. Rejected as a change to ADR-098’s trust boundary rather than to Studio: it would make the platform the producer of a creator’s binary, and the ingestion gate exists precisely because the platform does not vouch for what a creator built.

Consequences (including negative)

  • A fourth workspace is a fourth build, signing and update pipeline. ADR-109 already names three-artefact-kinds as its largest ongoing cost; this adds to it.
  • The Forge carries a Rust toolchain dependency onto a creator’s machine, and a toolchain that drifts from the one CI pins produces a module the silo may refuse. Decision 2’s engine probe is what turns that from a publish-time surprise into a build-time message; the pin itself remains a distribution problem this ADR does not solve.
  • Studio still has no code, so every enforcement point below is a requirement on a build that does not exist. Naming them as though they were present is the defect ADR-045 clause 2 exists to prevent, so they are recorded in §11’s open items instead.

Enforcement

  • The pipeline itself exists and is testable todaytools/forge/, with cartridge-shape.test.mjs in the frontend’s Vitest run. That is the half of this ADR that is not waiting on Studio.
  • The workspace’s own gates are owed: OI-V-06 records the Forge’s compute-module publish gate and the engine probe’s place in it, alongside OI-V-04’s existing entry for the Native AOT gate.

§11 Index Line

ADR-148: The Ruleset Forge is a FOURTH Descent Studio workspace beside World Builder, the Cinematic Replay Editor and the Asset Manager, installed on demand so a creator who only makes maps never downloads a Rust toolchain; the brief's reason was memory isolation from map-making and the stronger one is that a cartridge compile is a cranelift-scale operation — 422 ms for a 3 MB module, paid on every build — plus a toolchain and an artefact cache, none of which shares a working set with meshes and textures; it owns ADR-147's build pipeline INCLUDING the engine probe, so a creator learns at build time rather than at publish time that their toolchain flags differ from the silo's; it must never ship its precompiled output, which is tied to the wasmtime version and target CPU that produced it and would make every runtime bump a fleet-wide CDN invalidation — a rule that binds the silo's deployment only, since V8 compiles the same payload in under 5 ms; its compute half is a child process by ADR-109's second artefact kind; and everything it reports stays advisory under ADR-098, because the ingestion gate remains the only trust boundary. (Amends 109; Depends-on 147.)


What this work owes and has not paid

P7 and CLAUDE.md §6 rule 5.

  • The real Worker plumbing has no automated test. worker-runtime-host.ts, plugin.worker.ts and plugin-runtime.worker.ts are exercised by the probe under Node’s worker_threads and by hand, not by a browser test. A Playwright spec asserting ADR-040’s enforcement point against a real browser Worker is owed — OI-V-05, recorded in testing-and-verification.md §10. The decisions those files implement are tested at T1 through PluginRuntimeHost; the wiring is not.
  • No isolate-cost measurement in a browser. ADR-146’s 18 MB per runtime is Node’s RSS. A browser’s Worker isolate may be cheaper or dearer and this work does not know which, which matters because it is the residual half of the objection ADR-040 rejected one-worker-per-plugin on.
  • Nothing here ran on Linux. Every figure is win-x64. The shape findings — zero imports, the proposal table, the 40.5 KiB leak — are properties of artefacts and are platform-independent; the timings are not, and the 15.3 ms timer floor in ADR-146 is specifically Windows’.
  • The plugin worker has no consumer. Nothing in App.tsx starts it, no plugin is registered, and the snapshot ADR-040 clause 4 describes is not yet produced by anything. This is the same gap audit §3.3 recorded against the silo’s engine, one tier down, and it is why no Q-ID was minted: the budgets have no workload to be calibrated against yet.
  • The generator has no unit tests of its own. Its coverage is that three committed outputs compile and their suites pass. ADR-145’s consequences say so.
  • core/descent-wasm-core/README.md does not exist and lib.rs cites it twiceREADME.md §5 for what it does not do” and README.md §2 records the trade”. Found while working in the crate; pre-existing, not introduced here, and raised rather than filled in, because the two sections it points at would have to be invented and P9 is explicit that a fabricated record is worse than a blank. No lint catches it: doc_reference_lint.py scans docs/ and these are Rust doc comments. The argument each citation stands in for is in the corpus already — §2’s trade is ADR-137 and audit §2.2, §5’s scope is audit §3.1 — so the cheapest honest repair is to cite those instead of writing a third copy.
  • CLAUDE.md §P1 says 134 ADRs; adr_link_lint.py reports the true count on every run. Not corrected here — this document is not its owner, and R35 declined the same edit for the same reason. It has now been wrong five times in three days.