Skip to content

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.

QuestionOutcomeWhat decided it
How does CoC 7e share code with BRP?A trait bundling policy typesThe 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 flagA 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 slower3.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 frameADR-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, measuredZero 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 nothingA 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-coc7e
node tools/forge/build-cartridge.mjs --crate plugins/cartridges --package descent-cartridge-coc7e --max-bytes 16777216 --engine-probe
dotnet publish apps/studio/Descent.Studio.Launchpad -c Release -r win-x64

The 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 ISuccessLadder exists. 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 60 where a BRP character has CON 12. Hit points are therefore a tenth of CON + SIZ rather 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

  1. descent-brp is 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 is no_std, has zero dependencies, and has no serialisation and no ABI in it.

  2. A derived system implements System, a trait bundling associated policy types — the ladder, the required characteristics, the derivation and the difficulty rule. descent-coc7e is one. Dispatch is static and a partially-implemented system does not compile, which is P3’s strongest tier: a type that cannot express the wrong thing.

  3. An expansion book is a RuleLayer, and rule flags are retired. A layer amends a closed set of four decision pointsderived, bands, difficulty, outcome — each with a pass-through default, so a layer that amends one thing implements one method.

  4. The stack is ordered and every amendment is attributed. LayerStack folds 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.

  5. The base ruleset must not name its expansions. descent-coc7e has no dependency on descent-coc7e-pulp and no reference to it in code; no_expansion_dependency.rs asserts 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.

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

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

  8. A ruleset cartridge draws no dice. input.rolls carries 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.

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

  2. Composition. k flags are 2^k configurations 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.

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

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

  5. 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 .wasm modules. 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 as input. 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 by AlcCartridgeLoader under 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 the descent-wasm-core DRBG 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-v1 already 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 of descent-wasm-core and 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 existderive and check — 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, through serde_json, so it needs reference-types and the engine probe’s ref=false rows 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.wasm157 306 bytes
Imports0
Exportsmemory, descent_alloc, descent_invoke
wasmtime, ref=true mv=false (what the silo ships)OK
For scale: descent_wasm_core.wasm3 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 in DECISION_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:

workloadinput bytesenvelope parseinput parserenderrulestotalserialisation
minimal2181.030 µs1.131 µs0.602 µs0.387 µs3.15 µs87.7%
typical3221.222 µs1.567 µs0.662 µs0.578 µs4.03 µs85.7%
heavy1 4561.848 µs5.367 µs0.617 µs3.043 µs10.87 µs72.0%

JSON against MessagePack, same struct, both directions:

workloadJSON encodeJSON decodeMsgPack encodeMsgPack decodeJSON bytesMsgPack bytes
minimal0.791 µs1.270 µs0.939 µs1.349 µs218148
typical1.051 µs1.776 µs1.135 µs1.921 µs322236
heavy2.812 µs6.098 µs2.708 µs6.498 µs1 4561 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

  1. The guest ABI payload stays UTF-8 JSON. contracts/guest-envelope/v1.schema.json is unchanged, the generator is unchanged, and no cartridge author has to parse a binary format.
  2. 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 a cargo run rather than a re-derivation.
  3. FlatBuffers is declined for a reason JSON’s speed does not enter into. flatc is 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-byte no_std template cartridge cannot carry a FlatBuffers runtime.
  4. 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.
  5. The trigger is stated, so this is a decision rather than a preference. Re-measure if a cartridge’s input exceeds 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 is descent-cartridge-abi’s Rust binding, emitted from the same emitRust call as descent-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

  1. 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. At Q-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.

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

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

  4. The interest set is a closed vocabulary and an unknown path is refused, on the client side, before the worker sees it. budget.ts already 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.

  5. Every value in the snapshot is the wire’s own integer, unconverted. rawX/rawY/rawZ are Q16.16 and rotation is 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.

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

  7. init carries 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 and check-network-boundary.mjs rule 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.

  8. The budgets arrive from the deployment and there is no default anywhere. ADR-040 consequence 1 and §3.1. VITE_PLUGIN_BUDGETS is 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.

  9. The development budget set lives in a module the bundler deletes, asserted by vite.config.ts’s module-graph plugin and by a dist/ 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’s FrozenZone), so such a plugin was never going to work.
  • The init message shape changed. Nothing outside this directory speaks it, and protocol.ts records 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 build and pnpm run test now build it, which adds a cargo build of Boa to both.
  • The real Worker plumbing still has no automated test. end-to-end.test.ts runs the real artefact through the real supervisor and snapshot producer with an in-process runtime host, which covers everything except the browser. OI-V-05 is 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 real descent_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’s forbidModulesInProduction and tools/check-bundle-absence.mjs — decision 9, on the module graph and on dist/ respectively, which fail differently.
  • tools/check-network-boundary.mjs rule 5 — the payload loader is the only module outside src/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

  1. 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'}) and fetch of a .wasm are 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.

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

  3. 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 takes fileExists as a parameter. A rule reachable only by installing a bad module and watching a window is a rule with no test.

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

  5. The Native AOT publish gate exists and does not stop at “it compiled”. --check runs 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.

  6. 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.mjs and in WasmSandboxEngine.DescribeShapeFailure.

  7. 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.16zero
Probe binary1.59 MB
RuntimeFeature.IsDynamicCodeSupported in the running exeFalse
The real Launchpad, published3.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’s WasmSandboxEngine in 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.Sandbox links 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 defect P2 exists to prevent.
  • A “development mode” that skips signature verification. Rejected on decision 2. What replaces it is pack-workspaces.mjs generating 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.slnx and studio-ci.yml, isolated on ADR-M-001’s reasoning. CLAUDE.md §5’s trap widens accordingly: a green dotnet test DescentVTT.Full.slnx now 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-083 is unset, so the contract support window is a rule with no number: HostContract.Current is 1 and both modules declare [1, 1], the only range that can exist.

Enforcement

  • studio-ci.yml’s native-aot jobOI-V-04, closed. It publishes both executables, packs and signs the workspaces, and runs --check on the published binary.
  • ModuleGateTests — the contract range naming which side is stale, the tampered manifest, the empty key set, and five traversal shapes against a fileExists that answers true for 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.

#MutationCaught by
1invocationVerdict stops skipping an unchanged worldsession.test.ts ×2
2invocationVerdict stops refusing while an invocation is in flightsession.test.ts ×4
3describeInterestFailure accepts an unknown field pathsnapshot.test.ts, end-to-end.test.ts ×4
4SnapshotCache never invalidates on a new world versionsnapshot.test.ts ×2
5check::grade reads the fumble floor before the targeta_skill_of_a_hundred_cannot_fumble_on_the_roll_it_cannot_fail
6LayerStack::fold records an amendment even when nothing changeddescent-brp ×2
7The cartridge silently skips an unknown layer id instead of refusingdispatch.rs
8ModuleGate skips the signature checkModuleGateTests ×3
9StaticContent’s traversal check matches a segment that cannot occurStaticContentTests ×2
10FrameCodec drops the announced-length ceilingFrameCodecTests
11abi::pack drops the 32-bit bound and truncates insteaddescent-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::drbg into a crate both guests share is the fix; a third copy of a frozen wire contract is not. Recorded in plugins/cartridges/README.md.
  • Descent.Sandbox is 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 Worker plumbing still has no browser testOI-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 — PluginStatus carries hud-overlay and 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.