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 todocs/audits/MegaEpic_Backend_Modernization_Audit.mdrenders as a 404 andrehype-markdown-link-resolverrefuses it at build time. R35 took the same route for the same reason and this follows it.
| Question | Outcome | What decided it |
|---|---|---|
| Which ABI does the client plugin worker speak? | The silo’s, unchanged | One 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 trigger | Not 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 bindings | The 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 available | A 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 invocation | A held instance leaks 40.5 KiB per invocation — the ABI has no free, by design |
componentize-dotnet for cartridges? | Declined, with a trigger | Audit §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:
| Engine | Version |
|---|---|
| Node | 24.18.1 (V8 13.6.233.17) |
| Chromium | Playwright 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 exist — apps/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
WasmGuestAbi — memory, 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
- The client plugin guest ABI is the same three names, byte for byte.
guest-abi.tsdeclares them andguest-abi.test.tsreadsWasmGuestAbi.csand fails if the two ever disagree. - 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.
- One artefact runs in both hosts.
descent_wasm_core.wasm— built once, forwasm32-unknown-unknown— is instantiated by wasmtime in the silo and byWebAssembly.Instancein 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. - The envelope gains an
inputfield (ADR-145), carrying ADR-040 clause 4’s per-frame snapshot as a pre-serialised fragment handed to the guest’s ownJSON.parse. Additive by the envelope’s own version rule, sovdoes not move. - 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. - The polyglot tier is additive and needs nothing further. A plugin written in Rust and
compiled to core
wasm32exports the same three names and runs on this host with no second code path.tools/forge/templates/cartridgeis 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) | Chromium | wasmtime, for comparison | |
|---|---|---|---|
| Compile the 3.03 MB payload (once) | 4.21 ms | 3.80 ms | 422 ms |
| Instantiate (per invocation) | 0.309 ms | 0.200 ms | — |
A 3d6+2 macro, fresh instance | 1.115 ms | 0.900 ms | 1.511 ms |
| — as a share of a 60 Hz frame | 6.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
Workerwith 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 hasfetch,WebSocket,indexedDBandimportScripts, 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
inputand 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.tsstates this at the field.
Enforcement
- The three names:
guest-abi.test.ts, which parsesWasmGuestAbi.csand 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 byguest-abi.test.tsagainst a fixture module carryingwasi_snapshot_preview1imports. - 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.witcontract 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
contracts/guest-envelope/v1.schema.jsonis the contract. The Rust, C# and TypeScript bindings are generated from it bytools/generate-guest-envelope.mjsand carry aDO NOT EDITbanner.- The prose lives in the schema, not in the generated files. Every
descriptionandx-descent.remarksblock is emitted as a doc comment in all three languages, so the load-bearing paragraphs — whyunixMillisis required, whymaxRecursionDepthhas no default — are in the one place a reader of the contract will find them rather than in whichever binding they happened to open. - The generated files are committed, which is the opposite of the wire codegen’s rule.
frontend-guidelines.md§12 keepsflatcoutput out of the tree and regenerates it every build; that is right for output consumed by one toolchain and wrong here, becausecargo buildanddotnet buildmust not acquire a dependency on Node to compile a struct. --checkis the enforcement point. It re-runs the generator and diffs, and it runs inpnpm run buildascheck:envelopeand insandbox-ci.ymlbefore the .NET restore.- 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. intentsandinputstay outside the schema’s description, deliberately. Both are carried as pre-serialised fragments —RawValuein Rust,JsonElementin C#,unknownin TypeScript — because the intent shape is chosen by the ruleset author, and because a parse-and-re-emit round trip renormalises numbers:1.0becoming1changes what a ruleset receives.
Alternatives Considered and Why Rejected
- A
.witworld andcomponentize-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.Sandboxis the one published package in the repository and its surface isISandboxEngine; a payload-specific envelope does not belong in it. The three consumers getInternalsVisibleToentries 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.
RequestLimitsis nowuintin C# rather thanint, because the schema saysuint32and the Rust side saysu32. 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 missingkeyis not a thing a caller should be able to construct.System.Text.Jsonenforces the same rule inbound. - A generated C# file needs
#nullable enableof its own. Roslyn treats any file named*.generated.csas auto-generated and excludes it from the project’s nullable context, so a nullable annotation in one is CS8669 — a warning underdotnet buildand 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--checkperforms 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— inpnpm run build(check:envelope) and insandbox-ci.yml, whose path filters now includecontracts/guest-envelope/**so a schema-only edit reaches the check that exists for it.protocol.rs’sa_request_without_the_added_input_field_still_deserialisesandan_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 spins | 15.55 ms median |
| — the same measurement with no spinning child | 15.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 instance | 30 → 68 pages |
| Per invocation | 40.5 KiB |
| Cost of a fresh instance | 0.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 guest | 510.6 ms | 447.2 ms |
| The same logic, in the host engine natively (batch of 50) | 0.351 ms | 0.344 ms |
| Ratio | 1 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
- The interrupt half of clause 2 is retired as unimplementable, not deferred.
- 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. - 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.
- 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.
- 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.
- 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. - 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.tsasserts 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.tsfor 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
Workerplumbing —worker-runtime-host.ts,plugin.worker.tsandplugin-runtime.worker.ts— has no automated test. It is exercised bytools/plugin-frame-budget-probe.mjsunder Node’sworker_threadsand by hand; a Playwright spec asserting the same properties against a real browserWorkeris owed and is recorded asOI-V-05intesting-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
- A cartridge is a core
wasm32-unknown-unknownmodule that declares zero imports and exports the three names. Not a component, no.witworld, no WASI. componentize-dotnetis 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 andBuildConfigsetsWithComponentModel(false); components target WASI and import their world, where preview 1 emitted eight imports and preview 2’s worlds are more structured; andcomponentize-dotnetis built on NativeAOT-LLVM, so a cartridge SDK would inherit the reflection ban thatDescent.Vtt.Sdk’sAttributeBagJsonConverterfails 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.- 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, throughcore/descent-wasm-core/examples/proposal_probe.rs.
- a shape gate (
- 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.validateaccepting 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. - The size ceiling has no default. It is host configuration —
WasmSandboxOptions.MaxModuleBytesin the silo — and a default here would become a second ceiling nobody set (ADR-100). tools/forge/templates/cartridgeis 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-types | multi-value | descent_wasm_core (std) | template cartridge (no_std) |
|---|---|---|---|
| off | off | REFUSED (offset 17984) | OK, 0 imports |
| on | off | OK | OK |
| off | on | REFUSED | OK |
| on | on | OK | OK |
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
.witpluscomponentize-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-wasip1andwasm32-wasip2both 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;
.witwould 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.targetsalready 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 againstWasmGuestAbi.cs, as the client’s suite does.build-cartridge.mjsexits non-zero on a non-conforming artefact, so it composes as a gate.- The engine probe’s assertion is on the row
BuildConfigactually 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
Ruleset Forgeis 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.- 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_probeagainst its own output before shipping it, so the creator learns at build time rather than at publish time.” - 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. - 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.
- 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 today —
tools/forge/, withcartridge-shape.test.mjsin 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-06records the Forge’s compute-module publish gate and the engine probe’s place in it, alongsideOI-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
Workerplumbing has no automated test.worker-runtime-host.ts,plugin.worker.tsandplugin-runtime.worker.tsare exercised by the probe under Node’sworker_threadsand by hand, not by a browser test. A Playwright spec asserting ADR-040’s enforcement point against a real browserWorkeris owed —OI-V-05, recorded intesting-and-verification.md§10. The decisions those files implement are tested at T1 throughPluginRuntimeHost; 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
Workerisolate 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.tsxstarts 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 noQ-IDwas 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.mddoes not exist andlib.rscites it twice — “README.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 andP9is explicit that a fabricated record is worse than a blank. No lint catches it:doc_reference_lint.pyscansdocs/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.pyreports 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.