Architecture Decision Rulings — R37
Architecture Decision Rulings — R37
Date: 2026-08-11 Scope: the last mile of the client plugin tier, the Descent Studio shell, the ruleset cartridge architecture, and the ABI payload format.
Four decisions: ADR-149 (a ruleset and an expansion book are two different relationships and
get two different mechanisms — a policy trait and an ordered rule layer, replacing rule flags),
ADR-150 (the ABI payload stays JSON, on a measurement that refutes the intuition behind
changing it), ADR-151 (the client plugin tier is driven by world change rather than by frames,
with one invocation outstanding per plugin — amends ADR-146), ADR-152 (Descent Studio exists,
serves its workspaces from a custom scheme with one origin each, verifies modules fail-closed, and
carries the Native AOT publish gate ADR-109 named — closes OI-V-04).
Three of the four close debts this corpus recorded against itself, which is the theme rather than a coincidence. R36’s own “what this work owes” section named the plugin tier’s missing consumer; ADR-109 and ADR-148 both named an enforcement point that did not exist “because Studio has no code”; and ADR-147’s consequences named the envelope-handling gap a cartridge author was left with.
| Question | Outcome | What decided it |
|---|---|---|
| How does CoC 7e share code with BRP? | A trait bundling policy types | The systems share machinery and disagree about nearly every number — a value override would interleave two systems’ arithmetic in one type |
| How is an expansion book modelled? | An ordered rule layer, never a flag | A flag has no locality, no composition, no attribution, no identity and no third-party path. A layer has all five |
| Is JSON a bottleneck for a heavy cartridge? | No, and MessagePack measured slower | 3.2–10.9 µs per invocation, and rmp-serde decodes this shape 7–10% slower than serde_json |
| What drives a plugin invocation? | A world change, not a frame | ADR-040 says “every frame”; ADR-146 measured the tier at ~1 300× and said it must not be on the frame path. The two cannot both be obeyed literally |
| Does Photino survive Native AOT? | Yes, measured | Zero IL2xxx with every analyser on, and IsDynamicCodeSupported = False in the running binary |
| Can a Studio module be loaded unverified? | No, and an unconfigured Studio loads nothing | A verifier that treats “no keys” as “checking is off” is disabled on every machine nobody set up |
0. What was measured, and on what
Every figure below was produced on 2026-08-11, on win-x64, with Rust 1.97.1, .NET 10.0.302 and Node 24.18.1.
Reproduction:
cargo run --release --example envelope_format_probe -p descent-cartridge-coc7enode tools/forge/build-cartridge.mjs --crate plugins/cartridges --package descent-cartridge-coc7e --max-bytes 16777216 --engine-probedotnet publish apps/studio/Descent.Studio.Launchpad -c Release -r win-x64The probe is retained, following R35 and R36’s precedent and for their reason: a document that is the only evidence for its own claims is a document nobody can check.
One figure in a draft of this document was wrong and the correction is recorded rather than
quietly applied. The first envelope probe reported serialisation at 95% of a minimal
invocation. Its render arm parsed the answer and re-serialised it — double-counting a parse the
cartridge never performs and attributing it to rendering. The corrected arm parses once outside the
loop and times only the write. The figures below are the corrected ones. The lesson is the one
R36 recorded about a quantity measured in three clock ticks, in a different shape: an arm that
measures more than it names is not a measurement of what it is labelled.
No Q-ID is minted here. P5 reserves the registry for a figure profiled against a target,
and none of these has been.
ADR-149 — A ruleset and an expansion book are two different relationships, and rule flags are retired
Status: Accepted
Date: 2026-08-11
Depends-on 147 (a cartridge is a zero-import core wasm32 module; these are cartridges);
Depends-on 145 (the generated envelope is what they speak); interacts with 016, 017,
055, 071, 134
Context
The brief asked two questions and they look like one: how should CoC 7e share code with BRP, and how should Pulp Cthulhu — previously “rule toggles/flags” — be modelled.
They are not one question, and treating them as one is the mistake this ADR exists to name. Call of Cthulhu 7th Edition is built on Basic Roleplaying; Pulp Cthulhu is an expansion for Call of Cthulhu. Those sentences use different words because they describe different things.
The C# implementation had already discovered the first half and its comments say so plainly.
CoC7ePolicies.cs:
“Four tiers where Basic Roleplaying has three, and none of the thresholds agree … This is the whole reason
ISuccessLadderexists. Had the kernel hard-coded BRP’s numbers, every one of these would be an override of a value rather than an implementation of a contract, and the two systems’ arithmetic would have been interleaved in one type.”
and on the least obvious of the three seams:
“Every formula here differs from the Basic Roleplaying rulebook’s, and the reason is one fact: CoC 7e characteristics are percentile. An investigator has
CON 60where a BRP character hasCON 12. Hit points are therefore a tenth ofCON + SIZrather than a half, and reusing BRP’s derivation would give that investigator 65 hit points instead of 13 — a number that looks unremarkable on a sheet and is catastrophic in play.”
The second half had no design at all. Pulp appears in the C# tree exactly twice: as a stray Id
string ("coc7e.pulp" on a type that is not about Pulp) and as a comment noting that Luck is
optional because “a pre-Pulp investigator sheet may not carry one”. There was no expansion
mechanism to port — which is what made this a green-field choice rather than a migration.
Decision
-
descent-brpis a kernel, generic over policy. It owns the d100 resolution, the success ladder’s reading, the difficulty mechanism and the resistance table, and knows no system’s numbers. It isno_std, has zero dependencies, and has no serialisation and no ABI in it. -
A derived system implements
System, a trait bundling associated policy types — the ladder, the required characteristics, the derivation and the difficulty rule.descent-coc7eis one. Dispatch is static and a partially-implemented system does not compile, which isP3’s strongest tier: a type that cannot express the wrong thing. -
An expansion book is a
RuleLayer, and rule flags are retired. A layer amends a closed set of four decision points —derived,bands,difficulty,outcome— each with a pass-through default, so a layer that amends one thing implements one method. -
The stack is ordered and every amendment is attributed.
LayerStackfolds each decision point through the layers in order and records{layer, decision, before, after}for every value that actually changed. A layer that returns its input is silent. -
The base ruleset must not name its expansions.
descent-coc7ehas no dependency ondescent-coc7e-pulpand no reference to it in code;no_expansion_dependency.rsasserts both, because a rule this important should not be a convention. The cartridge names both, which is a bill of materials rather than a flag. -
The active stack is declared by the host in the envelope’s
input, in order, and an unknown layer id is refused. Never inferred, never defaulted to “everything available”, never silently skipped.budget.ts’s argument about truncated interest sets transfers exactly: a plugin that is quietly given less than it asked for produces wrong answers rather than no answers. A Pulp game that silently ran without Pulp is discovered from a character dying. -
The stack’s ids and the amendments travel in the answer, so an execution replays from its envelope and a player can be told which book changed their number.
-
A ruleset cartridge draws no dice.
input.rollscarries d100 results the authority that owns them already drew. See the consequences for the third reason, which is the load-bearing one.
Why a layer beats a flag — five properties, and a flag has none of them
This is the part the brief asked for, so it is argued rather than asserted.
-
Locality. A flag is read wherever it applies, so adding an expansion means editing the base ruleset in as many places as the expansion touches. A layer is one type in one crate, and decision 5 makes “CoC 7e contains no Pulp” a checked property rather than a claim.
-
Composition.
kflags are2^kconfigurations and nothing tests the interactions. A stack has one dimension — order — and the order is data, so it can be recorded, replayed and shown. The honest consequence is asserted rather than hidden: two doubling layers give four times, not twice, because a fold is not idempotent where a flag is. That makes ordering the expansion author’s visible problem instead of nobody’s invisible one. -
Attribution. A player asking why their investigator has 24 hit points gets a number from a flag and a sentence from a layer:
coc7e/pulp · derived · hp 12 → hp 24. This is ADR-040 clause 5’s argument in a different subsystem — “a budget event that does not name the plugin leaves ‘the VTT is slow’ unattributable” — and it is the property that is simply unavailable from a boolean. -
Identity. A flag cannot be versioned, signed, licensed, sold, entitled or deprecated. A layer has an id, so ADR-071’s cartridge deprecation window (
Q-046), ADR-136’s signature suite and ADR-095’s prefix families all apply to it unchanged, and the Marketplace can sell one. -
Third-party extensibility. Nobody can ship a flag into somebody else’s
if. A layer is a type implementing a public trait.
And one property a flag has that a layer must not lose: a flag is cheap. The stack is &[&dyn RuleLayer] over values the cartridge already holds, folded once per decision point per invocation
— which the probe in ADR-150 measures as part of a 3.2 µs call.
Why the decision points are a CLOSED set
The tempting generalisation is “a layer may run arbitrary code at any point”, and it is refused. An open extension surface is a plugin API, and a plugin API has none of the five properties above either — because nobody can say what a plugin changed. A closed set of decision points is what makes attribution possible at all, and it is the constraint the design is built on rather than a limitation of it.
Adding a decision point is a deliberate edit and is a host-contract change: an older host folding three points where a layer expects four would silently drop the fourth.
Alternatives Considered and Why Rejected
- A boolean flag or a feature set on the ruleset. The status quo. Rejected on all five properties above, and the decisive one is attribution: this platform’s whole authoring story is a creator diagnosing content they did not write.
- Pulp as a second
System. Rejected. It would mean re-declaring every policy CoC 7e already has in order to change one of them, and two systems that agree about the ladder would then have two ladders that must be kept in step by hand — the drift this corpus records most often. - Cargo features (
--features pulp). Rejected, and it is the closest alternative because it is compile-time composition. It fails on decisions 6 and 7: a feature is fixed at build time, so a table running Pulp and a table running plain CoC 7e need two cartridges, and the entitlement question — this creator bought Pulp — becomes a distribution problem rather than a runtime one. It also fails attribution: a feature leaves no trace in the answer. - ECS composition, which the brief named. Rejected as a category error rather than as a bad idea. An ECS composes entities out of components and is an excellent fit for characters and effects; what varies between CoC 7e and Pulp is not a character’s component set but which function computes a number, and an ECS has no opinion about that. The layer stack is what an ECS’s system ordering would be if it were about rules rather than about entities.
- Each expansion as its own cartridge, composed by the host chaining
.wasmmodules. Not rejected — not implemented, and it is the natural extension. A zero-import module cannot call into another, so composing two artefacts means the host feeding one’s answer to the next asinput. That is genuinely the right shape for a third-party expansion and it costs an instantiation per layer; in-module layers are what a publisher shipping both books wants. Stated here so the gap is a known boundary rather than a missing feature.
Consequences (including negative)
- There are now two implementations of Call of Cthulhu 7th Edition, one in C#
(
Descent.Vtt.Plugins.CoC7e, loaded byAlcCartridgeLoaderunder ADR-134) and one in Rust. Nothing routes a room’s action to the wasm32 one, so the second is not yet load-bearing — but two implementations of one ruleset is a real cost and it is stated rather than absorbed. ADR-017’s single-implementation rule is about the geometry path and does not reach this, and thedescent-wasm-coreDRBG precedent (ADR-141) shows the exemption’s shape if one is ever needed: divergence must be structurally detectable. - A cartridge takes its dice as input, which is decision 8. The reason it is not the other way
round is concrete:
DESCENT-DRBG-HMACSHA256-CTR-v1already exists twice, and ADR-141 admits the second copy only because a parity gate compares them live. A third copy needs a third gate. The right fix is to move the Rust one into a shared crate, which is a refactor ofdescent-wasm-coreand its parity tests — and doing that inside a ruleset-architecture change would make one review of two things. Owed, and recorded in the workspace README. - The Pulp layer implements two rules. Doubled hit points is the book’s signature change; the
talent mechanism is the book’s shape with the roster left to the character sheet, because a crate
that hardcoded a roster would be asserting the contents of a published work it has not been
checked against (
P9). Growing it is content; the architecture does not change when the third rule arrives, which is the property being demonstrated. - Only two actions exist —
deriveandcheck— deliberately the two the C# engine handled, so the port is comparable. Sanity checks, damage, opposed contests and the skill catalogue are not ported. - The cartridge links
std, throughserde_json, so it needsreference-typesand the engine probe’sref=falserows are REFUSED. That is ADR-147’s F-2 narrowing working exactly as recorded and does not reopen ADR-140.
The measurement
| Measured | |
|---|---|
descent_cartridge_coc7e.wasm | 157 306 bytes |
| Imports | 0 |
| Exports | memory, descent_alloc, descent_invoke |
wasmtime, ref=true mv=false (what the silo ships) | OK |
For scale: descent_wasm_core.wasm | 3 180 219 bytes |
A ruleset cartridge is one twentieth of the JavaScript payload, because it carries rules rather than an interpreter. That ratio is the concrete form of ADR-146’s conclusion that the polyglot tier is the answer for anything with a per-frame budget.
Enforcement
descent-coc7e/tests/no_expansion_dependency.rs— decision 5, asserted two ways: the manifest declares no edge, and no source file references the crate in code. A scan that saw no files fails, because a guard over a path that could never exist passes forever while testing nothing (Architecture_Decision_Rulings_R4.md).descent-brp/src/layer.rs’s suite — the fold, the silence of a no-op layer, the ordering, and that every recorded decision name is inDECISION_POINTS.descent-cartridge-coc7e/tests/dispatch.rs— decisions 6 and 7 end to end through a real envelope, including that an unknown layer is refused and names what is available.tools/forge/build-cartridge.mjs --engine-probe— ADR-147’s gate, run against this cartridge, reported above.
§11 Index Line
ADR-149: A ruleset and an expansion book are TWO relationships and get two mechanisms. BRP → CoC 7e is kernel-and-policy: descent-brp owns the d100 resolution, the ladder reading, the difficulty mechanism and the resistance table with no system's numbers in it, and a derived system implements System, a trait bundling associated policy types — static dispatch, and a partially-implemented system does not compile. CoC 7e → Pulp Cthulhu is content layering: an expansion is a RuleLayeramending a CLOSED set of four decision points, folded in declared order by aLayerStackthat records {layer, decision, before, after} for every value that changed. RULE FLAGS ARE RETIRED, on five properties a boolean has none of — locality (the base ruleset must not name its expansions, asserted by a test on both the manifest and the source), composition (k flags are 2^k untested configurations; a stack has one dimension and it is data), attribution (ADR-040 clause 5's argument in another subsystem — a player asking why they have 24 hit points gets a sentence naming the book), identity (a layer can be versioned, signed, deprecated under Q-046 and SOLD; a flag cannot), and third-party extensibility (nobody ships a flag into somebody else'sif). The decision points are closed deliberately: an open surface is a plugin API and nobody can say what a plugin changed, so a closed set is what makes attribution possible at all. Cargo features were the closest alternative and fail on entitlement and attribution; ECS composition was rejected as a category error, since what varies is which function computes a number rather than a character's component set; cross-module composition by host-chaining .wasm artefacts is the right shape for a third-party expansion and is NOT implemented. The active stack is declared by the host in input, in order, and an unknown layer id is REFUSED rather than skipped — a Pulp game that silently ran without Pulp is discovered from a character dying. A ruleset cartridge draws no dice: the authority that owns them supplies them, which is ADR-016's tiers and avoids a third implementation of a frozen DRBG contract that would need a third parity gate. Measured: 157 306 bytes, zero imports, accepted by wasmtime under the configuration the silo ships — one twentieth of the JavaScript payload. (Depends-on 147; Depends-on 145.)
ADR-150 — The ABI payload stays JSON, and the measurement refutes the intuition
Status: Accepted Date: 2026-08-11 Depends-on 145 (the schema and its generated bindings are what would have to change); interacts with 137, 147, 149
Context
ADR-145 wrote the guest envelope down once as a JSON Schema and generated three bindings from it. The obvious next question — and the one this epic was explicitly empowered to answer either way — is whether JSON is the right carrier once a cartridge is doing real ruleset work. The platform already speaks two binary formats: FlatBuffers on the game wire, MessagePack under SignalR.
The audit’s §5.3 names the failure mode for answering this from first principles: “an attractive unification refuted by a budget nobody checked first”. So it was measured.
What was measured
plugins/cartridges/descent-cartridge-coc7e/examples/envelope_format_probe.rs, 20 000 iterations
per arm, three payload sizes. The four arms are the four things dispatch actually does.
Where an invocation’s time goes:
| workload | input bytes | envelope parse | input parse | render | rules | total | serialisation |
|---|---|---|---|---|---|---|---|
| minimal | 218 | 1.030 µs | 1.131 µs | 0.602 µs | 0.387 µs | 3.15 µs | 87.7% |
| typical | 322 | 1.222 µs | 1.567 µs | 0.662 µs | 0.578 µs | 4.03 µs | 85.7% |
| heavy | 1 456 | 1.848 µs | 5.367 µs | 0.617 µs | 3.043 µs | 10.87 µs | 72.0% |
JSON against MessagePack, same struct, both directions:
| workload | JSON encode | JSON decode | MsgPack encode | MsgPack decode | JSON bytes | MsgPack bytes |
|---|---|---|---|---|---|---|
| minimal | 0.791 µs | 1.270 µs | 0.939 µs | 1.349 µs | 218 | 148 |
| typical | 1.051 µs | 1.776 µs | 1.135 µs | 1.921 µs | 322 | 236 |
| heavy | 2.812 µs | 6.098 µs | 2.708 µs | 6.498 µs | 1 456 | 1 252 |
Two findings, and the second is the one that decides it.
1. Serialisation is most of a cartridge invocation — 72% to 88%. Taken alone that reads as a bottleneck, and it is the number a proposal to change the format would quote.
2. The whole invocation is microseconds, and MessagePack is not faster. 10.87 µs at the heavy
workload is 0.065% of a 60 Hz frame. And rmp-serde decodes this shape 7% to 10% slower
than serde_json — measured in both directions, at all three sizes, reproducibly across runs. The
share is large because the rules are cheap, not because the serialisation is expensive.
The “binary is faster” intuition is refuted for this shape and it is worth saying why: these are
small documents dominated by short string keys and small integers, where serde_json’s tight
scanner beats a length-prefixed format’s per-field dispatch. MessagePack wins on size —
14% to 32% smaller — and the payload never travels a network.
Decision
- The guest ABI payload stays UTF-8 JSON.
contracts/guest-envelope/v1.schema.jsonis unchanged, the generator is unchanged, and no cartridge author has to parse a binary format. - MessagePack is declined, on the measurement rather than on preference. It is retained as a
[dev-dependencies]entry in the probe only, so re-running the comparison after a change is acargo runrather than a re-derivation. - FlatBuffers is declined for a reason JSON’s speed does not enter into.
flatcis pinned at exactly 25.2.10 with six mirror locations (protocol-and-wire.md§4). Adopting it here would put that pin into every cartridge author’s build, and ADR-147’s whole argument for the core module ABI is that “the contract is three exported symbols and a byte range, not a framework”. A 336-byteno_stdtemplate cartridge cannot carry a FlatBuffers runtime. - The Studio module IPC payload is JSON for a related but distinct reason (ADR-152 decision 4): it is a control channel of a few kilobytes per build, and §1.6.3’s own stated cost of that boundary is that “debuggability gets worse” — a frame a developer can read is the cheapest mitigation available for exactly that.
- The trigger is stated, so this is a decision rather than a preference. Re-measure if a
cartridge’s
inputexceeds roughly 100 KB per invocation — a full world snapshot for a client-side ruleset at 20 Hz would reach it. The constants above are for documents of a few hundred bytes to a few kilobytes, and a scan cost that is linear in the payload changes character when the payload does.
A finding that is worth more than the format question
The input’s bytes are parsed twice. The envelope parse scans input to find its extent (it is
a RawValue), and the cartridge then parses it again as its own request type. At the heavy
workload that is 1.85 µs of the 7.2 µs spent on serialisation.
It is inherent to the nesting rather than to the format — MessagePack would pay the same double scan — and it is the one avoidable cost the probe found. Removing it means either flattening the cartridge request into the envelope (which would put a ruleset’s shape into a contract three languages read, and ADR-145 decision 6 refuses that deliberately) or a streaming parser that hands the inner document’s slice straight through. Recorded rather than acted on: it is 17% of an invocation that is 0.065% of a frame.
Alternatives Considered and Why Rejected
- MessagePack for the guest envelope. Declined on the measurement above. Its real advantage is size, on a payload that never leaves the process.
- FlatBuffers for the guest envelope. Declined on decision 3’s toolchain argument, which is independent of any timing. It is also the wrong shape: FlatBuffers’ advantage is zero-copy access to a large buffer read partially, and a cartridge reads all of its input.
- A hybrid — binary envelope, JSON
input. Rejected as the worst of both: two formats to generate, two to document, and the envelope parse is the smaller of the two arms. - Doing nothing and not measuring. Rejected because the question was asked.
P2: the answer “JSON is probably fine” and the answer “MessagePack decodes this shape 7% slower” look the same until somebody proposes the change.
Consequences (including negative)
- A community author reads and writes the envelope in whatever their language calls JSON, which
is the property decision 1 exists to keep. ADR-147’s stated cost — “a cartridge author
hand-writes their envelope handling” — is unchanged and is now bounded by
descent-cartridge-abi, which does the boilerplate for a Rust author. - The size difference is real and is being paid. 14–32% more bytes per invocation, in a guest whose heap is the thing being bounded. At these sizes it is tens of bytes to a few hundred.
- The double parse stays, above.
- The figures are host-target, not guest. Both arms are the same code compiled the same way and wasm is uniformly slower for this work, so the ratios transfer and the times do not. Stated rather than glossed; a guest-side arm is owed if the trigger in decision 5 ever fires.
Enforcement
node tools/generate-guest-envelope.mjs --check, unchanged, and now covering four outputs: the fourth isdescent-cartridge-abi’s Rust binding, emitted from the sameemitRustcall asdescent-wasm-core’s so the two cannot drift.- The probe itself, retained and runnable, which is what makes decision 5’s trigger checkable rather than rhetorical.
§11 Index Line
ADR-150: The guest ABI payload stays UTF-8 JSON, and the measurement refutes the intuition behind changing it. Measured 2026-08-11 over a real CoC 7e cartridge at three payload sizes: serialisation is 72–88% of an invocation, which reads as a bottleneck until the second figure — the WHOLE invocation is 3.15 to 10.87 µs, or 0.065% of a 60 Hz frame at the heavy workload, and rmp-serde decodes the same struct 7–10% SLOWER than serde_json in both directions at all three sizes. The share is large because the rules are cheap, not because the serialisation is dear; MessagePack's real advantage is size (14–32% smaller) on a payload that never leaves the process. FlatBuffers is declined on a ground that does not involve timing at all: flatc is pinned at exactly 25.2.10 with six mirror locations, and adopting it would put that pin into every cartridge author's build, against ADR-147's argument that the contract is three exported symbols and a byte range rather than a framework — a 336-byte no_std template cannot carry a FlatBuffers runtime. A hybrid binary-envelope/JSON-input was rejected as the worst of both. The probe found one avoidable cost that is NOT about the format: the input's bytes are parsed twice, once by the envelope's RawValue scan and once by the cartridge, worth 1.85 µs of the heavy workload — inherent to the nesting, which MessagePack would also pay, and recorded rather than acted on. TRIGGER: re-measure if a cartridge's input exceeds roughly 100 KB per invocation, which a full world snapshot at 20 Hz would reach. The figures are host-target and the ratios transfer where the times do not. Enforced by the retained probe and by --check now covering four generated outputs. (Depends-on 145.)
ADR-151 — The client plugin tier is driven by world change, with one invocation outstanding per plugin
Status: Accepted
Date: 2026-08-11
Amends 146 (its init message carries a URL rather than a compiled module); Amends 040
(clause 4’s “every frame” is resolved against ADR-146’s own conclusion); interacts with 053,
063, 096
Context
R36’s “what this work owes” recorded the gap in one sentence: “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.”
Closing it required deciding three things the supervisor’s design deliberately left to its caller, and one of them is a contradiction between two accepted ADRs.
ADR-040 clause 4 says the snapshot is copied into the guest heap every frame. ADR-146 measured the JavaScript tier at roughly 1 300× the browser’s own JavaScript and concluded, in as many words, that “the JS tier must not be on the frame path.” Both are accepted. They cannot both be obeyed literally.
Decision
-
A frame boundary is posted every animation frame; an invocation is queued when the world changes. The frame message is accounting — it clears the aggregate and drains what the new frame has room for — and costs a
postMessage. The snapshot is a function of world state, and a deterministic guest over byte-identical input produces a byte-identical answer, so re-invoking on an unchanged world spends guest milliseconds to learn nothing. AtQ-001’s 20 Hz this is at most a third of the invocations “every frame” would produce at 60 Hz, and the ones removed are exactly the ones that could not have changed anything. -
A plugin has at most one invocation outstanding. Without this bound the queue grows without limit the moment a plugin is slower than the world cadence, and the supervisor’s FIFO deferral — which is correct, and is ADR-040 clause 2’s own rule — becomes a backlog that can never drain. A plugin that misses a tick sees the next one; it does not accumulate a debt of stale worlds.
-
The frame boundary is posted even when nothing is queued. A frame that skipped it would not clear the aggregate either, so the next frame with work in it would find the budget already spent by a frame that had ended.
-
The interest set is a closed vocabulary and an unknown path is refused, on the client side, before the worker sees it.
budget.tsalready refuses an oversized interest set rather than truncating it, on the ground that “a truncated interest set produces wrong answers instead of no answers”; the same argument applies one level down. Only the main thread knows which paths it can fill. -
Every value in the snapshot is the wire’s own integer, unconverted.
rawX/rawY/rawZare Q16.16 androtationis a fraction of a turn. ADR-017 forbids float on a path both hosts compute; a snapshot that helpfully divided by 65 536 would hand a plugin a float the silo never had, and a disagreement would be a rounding error nobody could find. -
The snapshot is serialised once per (interest set, world version) pair, not once per plugin. ADR-040 clause 4 names the cost as proportional to (interest set × plugin count); two plugins declaring the same paths want byte-identical input.
-
initcarries a payload URL and the supervisor compiles it. This amends ADR-146’s message shape and not its property: one compile, cloned to N runtimes that share the compiled code, is unchanged. What moved is which thread pays for the single compile, for two reasons — §9.6.1 andcheck-network-boundary.mjsrule 2 keep the main thread off the network, and an exemption there would be on the one thread the rule exists to protect; and a 3 MB compile is a third of a 60 Hz frame on the thread that draws, even at V8’s measured sub-5 ms. -
The budgets arrive from the deployment and there is no default anywhere. ADR-040 consequence 1 and §3.1.
VITE_PLUGIN_BUDGETSis parsed into three outcomes — configured, absent (a supported deployment) and unusable (a defect somebody must fix) — because collapsing the last two would hide a typo behind a supported state. -
The development budget set lives in a module the bundler deletes, asserted by
vite.config.ts’s module-graph plugin and by adist/scan. ADR-040 forbids budget constants in this repository because a published budget is one an abusive plugin calibrates against — and a constant that provably does not exist in a production build is not one.
Alternatives Considered and Why Rejected
- Invoking every animation frame, as clause 4 says. Rejected on ADR-146’s own measurement. It would triple the invocation count to produce identical answers, and the tier would spend its aggregate ceiling being deferred — correctly and uselessly.
- Driving invocations from the frame loop and diffing the world inside the worker. Rejected: the diff needs the previous snapshot, which is the state ADR-146’s fresh-instance rule exists to eliminate, and it would move a decision the main thread can make for free onto a thread that would have to be told.
- Letting the queue grow and relying on the token bucket. Rejected. The bucket bounds rate and not depth; a plugin deferred for a minute would accumulate 1 200 stale snapshots and then answer the oldest first.
- Compiling the module on the main thread and transferring it, as ADR-146 specified. Rejected on decision 7’s two reasons. The property that mattered in ADR-146 — shared compiled code across runtimes — is preserved exactly.
- A default budget set for development. Rejected as the thing ADR-040 forbids. The bundler-deleted fixture is what makes the constants admissible.
Consequences (including negative)
- A plugin cannot be invoked more often than the world moves. A plugin that wants a timer does not have one, and that is the correct answer for this tier rather than a limitation to work around: ADR-146 already concluded the JavaScript tier must not be on the frame path.
- A still room invokes nothing at all, so a plugin whose output depends on wall-clock time will
appear frozen. The guest’s clock is frozen per invocation anyway (
js.rs’sFrozenZone), so such a plugin was never going to work. - The
initmessage shape changed. Nothing outside this directory speaks it, andprotocol.tsrecords the correction beside the field rather than replacing the comment. - The client now fetches a 3.03 MB payload when a plugin tier is configured, and its absence is
a supported state reported by name.
pnpm run buildandpnpm run testnow build it, which adds a cargo build of Boa to both. - The real
Workerplumbing still has no automated test.end-to-end.test.tsruns the real artefact through the real supervisor and snapshot producer with an in-process runtime host, which covers everything except the browser.OI-V-05is unchanged.
Enforcement
session.test.ts— the cadence verdict’s four outcomes, the in-flight bound, the unchanged-world skip, the frame boundary posted on an empty queue, and the refusal to start without a complete budget set.snapshot.test.ts— the closed vocabulary, the refusal naming the vocabulary, the unconverted integers, and that the cache does not answer a new version from an old projection.end-to-end.test.ts— the whole chain against the realdescent_wasm_core.wasm: world state → projection → envelope → guest → intents, with the frozen clock and the keyed DRBG asserted, plus that the artefact declares zero imports.vite.config.ts’sforbidModulesInProductionandtools/check-bundle-absence.mjs— decision 9, on the module graph and ondist/respectively, which fail differently.tools/check-network-boundary.mjsrule 5 — the payload loader is the only module outsidesrc/network/that may fetch, and it may only be imported by the plugin worker, because the exemption is justified entirely by which thread it runs on.
§11 Index Line
ADR-151: The client plugin tier's consumer exists, and building it resolved a contradiction between two accepted ADRs: clause 4 of ADR-040 copies the snapshot into the guest EVERY FRAME, and ADR-146 measured the JS tier at ~1 300x and concluded it must not be on the frame path. The resolution is that a FRAME BOUNDARY is posted every animation frame — accounting, one postMessage, and posted even on an empty queue because a frame that skipped it would not clear the aggregate either — while an INVOCATION is queued when the WORLD changes, since a deterministic guest over byte-identical input answers identically and the invocations removed are exactly the ones that could not have changed anything. A plugin has at most ONE invocation outstanding: without that bound the queue grows forever the moment a plugin is slower than the world cadence, and the supervisor's correct FIFO deferral becomes a backlog that cannot drain. The interest set is a CLOSED vocabulary refused on the main thread before the worker sees it, on budget.ts's own argument that a truncated interest set produces wrong answers instead of no answers; the snapshot carries the wire's own Q16.16 integers unconverted, because a helpful division would hand a plugin a float the silo never had; and it is serialised once per (interest set, world version) rather than once per plugin, which is the cost clause 4 names. The init message now carries a payload URL rather than a compiled Module, which AMENDS ADR-146's message and not its property — one compile cloned to N runtimes sharing compiled code is unchanged, and what moved is which thread pays, because the main thread must not fetch and a 3 MB compile is a third of a frame on the thread that draws. Budgets arrive from the deployment with no default anywhere, parsed into three outcomes so a typo is not hidden behind the supported "absent" state, and the development set lives in a module two checks prove the bundler deletes. (Amends 146; Amends 040.)
ADR-152 — Descent Studio exists: one origin per workspace, fail-closed verification, and the AOT gate ADR-109 named
Status: Accepted
Date: 2026-08-11
Implements 109 (its Launchpad, its three artefact kinds and its IPC seam); Implements 148
(the Ruleset Forge workspace); closes OI-V-04; interacts with 098, 147
Context
ADR-109’s Enforcement names the Native AOT publish gate for the managed-assembly prohibition and
then says: “This gate does not exist yet, because Studio has no code.” §1.6.2 says the same.
ADR-148 says it again for the Forge’s own publish gate. OI-V-04 and OI-V-06 recorded both.
Studio has code now, so the gate can exist — and building it forced four decisions the design documents did not make.
Decision
-
Workspaces are served from a custom
app://scheme with one host per workspace.Not
file://, and this is load-bearing rather than a preference. §2’s entire reason for Photino is “100% UI reuse of the VTT’s SolidJS + Babylon.js + Monaco + Rete.js stack”, and that stack does not run from a file URL: ES module scripts,new Worker(new URL(…, import.meta.url), {type: 'module'})andfetchof a.wasmare all subject to origin rules a file URL fails. ADR-053’s client topology is three workers; a shell that could not load one would be reusing the design and not the code.One host per workspace, so two workspaces are two origins with separate
localStorage, IndexedDB and service-worker scope. ADR-109 decision 5 protects a creator from a substituted module; a shared origin would hand a verified-but-hostile module the others’ storage anyway. -
Module verification fails closed. A Launchpad with no trusted keys loads nothing. The tempting alternative — treating “no keys configured” as “verification is off” — disables the control on every machine nobody set up, which is all of them by default. Signatures are ECDSA P-256 over the manifest’s exact bytes, never over a re-serialisation, so a formatting change in a future JSON writer cannot invalidate every signed manifest in the field.
-
The gate is pure and the discovery is not. Every rule ADR-109 states — the contract range, the signature, the declared artefacts being present, a path staying inside its module directory — is in
ModuleGate, which has no file system in it and takesfileExistsas a parameter. A rule reachable only by installing a bad module and watching a window is a rule with no test. -
The Launchpad↔module payload is length-prefixed JSON. §1.6.3 asks for “length-prefixed messages … carrying the wire format the platform already uses”, and the framing is exactly that. The payload is JSON rather than FlatBuffers or MessagePack, for a reason ADR-150’s measurement does not cover: this is a control channel of a few kilobytes per build, FlatBuffers would put
flatc’s six-mirror pin into Studio’s build for it, and §1.6.3’s own stated cost of this boundary is that “debuggability gets worse” — a frame a developer can read is the cheapest mitigation for exactly that.A length prefix rather than newline delimiting, even though the payload is text: a newline framing makes the transport a function of the payload’s contents, and the Forge’s entire output is compiler diagnostics.
-
The Native AOT publish gate exists and does not stop at “it compiled”.
--checkruns the published binary headless: it discovers workspaces, verifies signatures and prints the gate’s verdict for each, exiting non-zero on any refusal. A publish that succeeds and a binary that runs are different facts, and this repository has recorded the gap twice. -
The Forge shells out to ADR-147’s pipeline rather than re-implementing its gates, so a creator’s machine needs Node beside the Rust toolchain ADR-148 records. A C# shape gate in Studio would be a third copy of a rule that already exists in
cartridge-shape.mjsand inWasmSandboxEngine.DescribeShapeFailure. -
Everything the Forge reports is advisory, and the sentence is appended by the module rather than by the UI, so a workspace that forgot cannot drop it. ADR-098 clause 5.
The measurements
Photino under Native AOT, probed with the Launchpad’s exact property set
(IlcTreatWarningsAsErrors, EnableTrimAnalyzer, EnableAotAnalyzer, EnableSingleFileAnalyzer,
JsonSerializerIsReflectionEnabledByDefault=false):
| Measured | |
|---|---|
IL2xxx / IL3xxx from Photino.NET 4.0.16 | zero |
| Probe binary | 1.59 MB |
RuntimeFeature.IsDynamicCodeSupported in the running exe | False |
| The real Launchpad, published | 3.75 MB + Photino.Native.dll + WebView2Loader.dll |
The last row of the probe is what makes it evidence rather than a JIT build wearing an AOT label,
and it is the standard Directory.Packages.props already holds the Key Vault entry to.
Uri normalisation, which turned a security check from decorative into real. Measured on
.NET 10: Uri.AbsolutePath collapses .., . and even %2e%2e, and no run of .. climbs above
the root — so the traversal refusal the first draft asserted could never fire. What survives is
an encoded separator: app://h/..%2flaunchpad/x.js keeps its %2f through normalisation and
becomes ../launchpad/x.js the moment a handler unescapes it, which it must do before touching a
file. That is the real traversal on this surface and it is what is refused now. The suite pins
both halves, so a runtime that stopped normalising fails a test rather than silently moving the
boundary.
Alternatives Considered and Why Rejected
file://, which every Photino sample uses. Rejected on decision 1. It is the difference between reusing the client and rewriting it.- One origin for all of Studio. Rejected on decision 1’s second half.
- Hosting
Descent.Sandbox’sWasmSandboxEnginein the Forge process, which §2’s stack table arguably calls for and which would make the pre-flight the silo’s own code path. Not rejected — not done, and the reason is stated rather than glossed:Descent.Sandboxlinks the Wasmtime .NET binding and whether that binding is Native AOT clean has not been measured. Every other AOT claim in this repository is backed by a probe; asserting this one without one would be the defectP2exists to prevent. - A “development mode” that skips signature verification. Rejected on decision 2. What replaces
it is
pack-workspaces.mjsgenerating a development key pair — which is exactly the case the verifier’s own docs describe, a creator running their own builds with their own key. - MessagePack for the IPC payload. Rejected on decision 4. ADR-150 measured it slower for a comparable shape, and the debuggability argument is independent of that.
Consequences (including negative)
- A fifth solution.
DescentVTT.Studio.slnxandstudio-ci.yml, isolated onADR-M-001’s reasoning. CLAUDE.md §5’s trap widens accordingly: a greendotnet test DescentVTT.Full.slnxnow says nothing about two other trees. - Studio needs Node, decision 6, on top of ADR-148’s Rust toolchain.
- There is no installer, so §1.5.2’s independently-downloadable model is a manifest format and a gate rather than a mechanism. The Launchpad says so when the modules directory is empty rather than showing a button that does nothing.
- The Forge’s UI is a form, not §4’s code lab. No Monaco, no Rete graph, no crate scaffolding. The chain beneath the Build button is real end to end.
- Only win-x64 has been built. The contracts are platform-independent; the Photino AOT measurement is not.
Q-083is unset, so the contract support window is a rule with no number:HostContract.Currentis1and both modules declare[1, 1], the only range that can exist.
Enforcement
studio-ci.yml’snative-aotjob —OI-V-04, closed. It publishes both executables, packs and signs the workspaces, and runs--checkon the published binary.ModuleGateTests— the contract range naming which side is stale, the tampered manifest, the empty key set, and five traversal shapes against afileExiststhat answerstruefor everything, so a gate that resolved before checking would pass.LaunchpadSessionTests— the routing: one workspace cannot serve another’s files, a refused module reads as refused rather than absent, a refusal is served as plain text, and a build goes to the open workspace rather than one the page names.FrameCodecTests— a clean EOF told apart from a truncated frame, an announced length bounded before it is allocated, and a dribbling stream reassembled.ModuleProcessTests— the seam against the real child process, including that a build with no size ceiling is refused across the process boundary.
§11 Index Line
ADR-152: Descent Studio exists, and ADR-109's Native AOT publish gate — named in its Enforcement and then disclaimed as "does not exist yet, because Studio has no code" — is now studio-ci.yml's native-aot job, closing OI-V-04. Workspaces are served from a custom app:// scheme with ONE HOST PER WORKSPACE, not file://: §2's reason for Photino is 100% reuse of the client's SolidJS/Babylon stack, and ES module scripts, module Workers and compileStreaming all fail from a file URL, so a file-served shell would be reusing the design and not the code; one host each means two workspaces are two origins with separate storage, because a shared origin would hand a verified-but-hostile module the others' data. Module verification FAILS CLOSED — a Launchpad with no trusted keys loads nothing, since treating "no keys configured" as "checking is off" disables the control on every machine nobody set up — and signatures are ECDSA P-256 over the manifest's exact bytes rather than a re-serialisation, so a future JSON writer cannot invalidate every signed manifest in the field. Every gate rule is in a pure type taking fileExists as a parameter, because a rule reachable only by installing a bad module and watching a window is a rule with no test. The Launchpad-to-module payload is length-prefixed JSON: the framing is §1.6.3's, and the payload is JSON because this is a control channel of kilobytes per build, FlatBuffers would put flatc's six-mirror pin into Studio's build, and §1.6.3's own stated cost of the boundary is that debuggability gets worse. The gate does not stop at "it compiled": --check runs the published binary headless and exits non-zero on a refused module. Measured 2026-08-11: Photino.NET 4.0.16 publishes with ZERO IL2xxx under every analyser and the running exe reports IsDynamicCodeSupported = False; and Uri.AbsolutePath normalises .., . and %2e%2e away, so the traversal refusal a first draft asserted could never fire — what survives is an encoded separator, and the suite now pins both halves. Hosting Descent.Sandbox in the Forge process is NOT done because the Wasmtime .NET binding's AOT-cleanliness has not been measured, and asserting it without a probe is what P2 forbids. (Implements 109; Implements 148.)
The mutation sweep
Eleven mutations, all caught. Run 2026-08-11 against the committed green baseline, one at a time, each reverted before the next. A green suite is not coverage: ADR-141’s own sweep found two of six mutations surviving, which meant an exemption was briefly backed by nothing.
The targets are the decisions above, not the code that carries them — a sweep over getters proves the getters work.
| # | Mutation | Caught by |
|---|---|---|
| 1 | invocationVerdict stops skipping an unchanged world | session.test.ts ×2 |
| 2 | invocationVerdict stops refusing while an invocation is in flight | session.test.ts ×4 |
| 3 | describeInterestFailure accepts an unknown field path | snapshot.test.ts, end-to-end.test.ts ×4 |
| 4 | SnapshotCache never invalidates on a new world version | snapshot.test.ts ×2 |
| 5 | check::grade reads the fumble floor before the target | a_skill_of_a_hundred_cannot_fumble_on_the_roll_it_cannot_fail |
| 6 | LayerStack::fold records an amendment even when nothing changed | descent-brp ×2 |
| 7 | The cartridge silently skips an unknown layer id instead of refusing | dispatch.rs |
| 8 | ModuleGate skips the signature check | ModuleGateTests ×3 |
| 9 | StaticContent’s traversal check matches a segment that cannot occur | StaticContentTests ×2 |
| 10 | FrameCodec drops the announced-length ceiling | FrameCodecTests |
| 11 | abi::pack drops the 32-bit bound and truncates instead | descent-cartridge-abi ×2 |
Two are worth their row on their own. Mutation 5 is the ordering bug that would make a
character with a skill of 100 fumble on the one roll they cannot fail — the kind of defect that
reaches a table rather than a build. Mutation 9 had to be written twice: refusing the check
outright is unreachable code, which TreatWarningsAsErrors rejects at compile time, so the
mutation that actually tests the suite is one that still compiles.
What this work owes and has not paid
P7 and CLAUDE.md §6 rule 5.
- There are two implementations of CoC 7e, C# and Rust, and nothing routes a room’s action to the Rust one. The wasm32 tier is architecture with no consumer — the same shape R36 recorded against the plugin worker and ADR-151 has just closed, one tier over.
- The cartridge draws no dice, and the reason is a refactor nobody has done. Extracting
descent-wasm-core::drbginto a crate both guests share is the fix; a third copy of a frozen wire contract is not. Recorded inplugins/cartridges/README.md. Descent.Sandboxis not hosted in the Forge, because the Wasmtime .NET binding has not been probed under Native AOT. That probe is a day’s work and would let Studio’s pre-flight run the silo’s own code path.- The real
Workerplumbing still has no browser test —OI-V-05, unchanged by ADR-151. - Studio has no installer and no cross-platform build. Everything is win-x64.
- The envelope’s double parse (ADR-150) is measured, understood and not fixed.
e2e/was not run. The Playwright suite needs a served build and a browser; the frontend’s Vitest suite (669 tests) and the new plugin suites were run in full, and the visual baselines were not. A new overlay panel was added to the HUD, so a visual baseline refresh is owed and has not been performed —PluginStatuscarrieshud-overlayand is therefore masked, which is the mechanism that should make it a no-op, but “should” is not “was checked”.- Nothing here ran on Linux, and the Photino, AOT and serialisation figures are all win-x64. The artefact facts — 157 306 bytes, zero imports, the proposal table — are properties of artefacts and are platform-independent.