Skip to content

Descent Studio — Architecture, and the Trust Boundary That Defines It

Descent Studio — Architecture, and the Trust Boundary That Defines It

Date: 2026-08-05 · Written in English per the 2026-08-01 convention change.

Status: authoritative for Studio. This document supersedes the extended design Descent Studio Architecture Whitepaper.md in the prior architecture iteration. That draft’s ecosystem concepts are largely correct and are carried forward here; several of its technical claims contradict this corpus and are corrected in place, with the rejections recorded in §9 rather than silently dropped.

The one-sentence version. Descent Studio is a local fat client that is not a trusted producer: it moves expensive authoring work onto the creator’s hardware, and the platform re-verifies everything it emits, because a local binary is an artifact the creator controls.


1. The thesis, and the boundary it implies

Authoring 3A tabletop content — importing million-triangle STL prints, running procedural generation over a hundred rooms, baking normal maps — requires hardware the browser cannot assume and cloud compute the platform will not pay for. Descent Studio moves that work to the creator’s machine and publishes the result, not the process.

This is the same shape as §6.2’s asset baking separation, applied one hop earlier: heavy, bursty, CPU-and-RAM-intensive work is kept away from anything with a latency obligation. §6.2 moved it off the real-time host into an ephemeral worker cluster; Studio moves a large share of it off the platform entirely.

The boundary that follows is the whole of this document’s security content. Because Studio runs on hardware the creator owns, every limit it enforces is advisory. A creator can patch the binary, or skip it and post to the ingestion API directly. Therefore:

Studio is a convenience for the creator and provides no assurance to the platform. The ingestion gate is the trust boundary, and it re-runs every check Studio ran.

The prior draft stated this correctly for script CPU limits. It is generalised here to every property Studio computes, because the argument does not depend on which property is being checked.


1.5 The Launchpad — Studio is a shell plus workspaces, not one application

Studio boots into a lightweight Launchpad, not into an editor. The user picks a workspace and the UI transitions wholly into that domain. This is a structural decision rather than a navigation preference, and it is made now because the alternative is not reversible cheaply: a single UI that accreted three domains would have to be pulled apart after its state was already shared between them.

The same argument §1 makes about where heavy work runs applies to what ships. §1 keeps bursty CPU work off the platform; §1.5 keeps it out of the creator’s install and out of the editor’s process.

1.5.1 The initial workspaces

Four since 2026-08-10, not three. ADR-148 added the Ruleset Forge; the full record is in docs/decisions/Architecture_Decision_Rulings_R36.md rather than in §10 below, because it was produced alongside the four client-side decisions it depends on and splitting a ruling set across two documents is how the halves drift apart.

WorkspaceDomainWhy it is its own workspace
World BuilderMap and campaign authoring — the entity model (§5), the code lab (§4), publishing (§6)The default, and the only one most creators open. Interactive, latency-sensitive, modest memory
Cinematic Replay EditorThe replay timeline, Director Filters and the cut document (Cinematic_Replay_Engine_Proposal.md)Consumes an archive rather than authoring one, so it shares almost no state with World Builder — a different verb on a different artefact
Asset ManagerBulk import and baking of million-triangle meshes; packaging Commercial Bundles for the MarketplaceThe heaviest compute in the product, and the only workspace that holds signing material. Both are reasons to keep it out of the editor
Ruleset Forge (ADR-148)Authoring, compiling and pre-flighting a cartridge — a core wasm32 module with zero imports (ADR-147)A cranelift-scale compile on every build, plus a Rust toolchain and a .wasm artefact cache. None of that shares a working set with meshes and textures, and a creator who only makes maps should never download it

The Ruleset Forge’s reason is not the one the brief gave for it, and the difference matters. The brief’s argument was memory isolation from map-making, which is true and is the weaker half. The Mega-Epic Audit §6.4’s argument is that the compile step is the workload: a cartridge is compiled by cranelift, a creator iterating a ruleset pays that on every build, and the toolchain and artefact cache come with it. Two constraints follow and both are recorded in ADR-148: the Forge must run the silo’s own engine configuration against its output at build time, so a creator learns about a toolchain mismatch then rather than at publish; and it must never ship its precompiled output, which is pinned to a wasmtime version and a target CPU.

The Asset Manager is new here and is not merely §3 renamed. §3 describes the asset forge as a capability; §1.5 makes it a separately-installed, separately-executed workspace, and the difference is where the work runs. A decimation pass over a million-triangle STL is unbounded in time and very large in memory, and §1’s own reasoning — heavy, bursty, CPU-and-RAM-intensive work is kept away from anything with a latency obligation — applies inside Studio exactly as it applies between Studio and the platform. A bake must not be able to stall, or exhaust the memory of, the window a creator has unsaved map edits in.

It also concentrates the Commercial Bundle path — DRM packaging and the creator’s signing material — in one workspace rather than spreading it through the editor. That is a smaller surface to reason about under ADR-098, and it is the reason to accept the extra process boundary even where the compute alone would have fitted.

1.5.2 Modules are installed on demand, and updated independently

A creator who only cuts replays must not download the procedural-generation toolchain. The model is the Visual Studio Installer’s: the Launchpad is small and always present; each workspace is an independently downloadable, independently updatable component.

Independent updatability is the requirement with teeth, because it forces a compatibility contract. Four rules follow:

  1. The Launchpad declares a host contract version; each module declares the range it supports. A module outside the range does not load, and says which side is stale. Silently loading a mismatched module is the failure this rule exists to prevent.
  2. The support window is Q-083 — expressed in releases, on the same reasoning as ADR-071’s cartridge deprecation window (Q-046): a window stated in releases is one a creator can plan against, and a window stated as “current” is one that breaks people.
  3. A module is signature-verified before it is executed or loaded. A module runs with the creator’s own privileges, and ADR-098’s “Studio is untrusted” runs the other way and does not cover this direction — that ADR protects the platform from the creator, not the creator from a substituted module. The download path also inherits ADR-039’s fetch bounds (Q-024, Q-025); a module is a fetched artefact like any other.
  4. The Launchpad works with zero workspaces installed, and its job when one is missing is to offer the install rather than to fail. That is what makes the base download small enough for the model to be worth anything.

1.6 How dynamic modularity works under Native AOT

The constraint is real and is not negotiable: Native AOT has no JIT and no IL loader, so Assembly.Load, AssemblyLoadContext and every managed-plugin pattern built on them are unavailable. Designing around that is the whole of this section. Saying it plainly matters because “just load a plugin DLL” is the first thing anyone proposes, and it does not merely perform badly here — the artefact cannot execute.

The resolution is that “a module” is not one kind of artefact. A workspace has two halves with completely different constraints, and conflating them is what makes the problem look impossible.

1.6.1 Three module kinds, in decreasing order of how much they ship

KindArtefactLoaded byAOT-legal because
UI module (most of the bulk)A signed web bundle — SolidJS views, Babylon scenes, Monaco/Rete configurationThe WebView, at runtimeIt is not .NET at all. The AOT constraint governs managed IL; the WebView loads JavaScript the way it always has
Compute module (the heavy work)A self-contained Native AOT executable, one per workspaceThe Launchpad, as a child process, over a versioned IPC contractNothing enters the host’s address space. Each module is AOT-compiled on its own, ahead of time, as its own binary
Hot-path native module (rare)A C-ABI shared libraryNativeLibrary.Load + GetExportNative code, not managed IL. This is the seam Descent.Geometry already uses, generalised

The first row carries most of the download and needs no cleverness whatsoever, which is the observation that makes the rest tractable. A workspace’s bulk is its UI — scenes, editors, node graphs — and that half was always dynamically loadable, because Photino is a WebView.

The second row is where the brief’s real requirement lives, and out-of-process is not a workaround accepted for AOT’s sake — it is independently the right answer for the Asset Manager (§1.5.1): crash isolation, memory isolation, cancellation by killing the process, and OS-level resource limits on the bake. A design forced by one constraint and independently justified by another is a design worth having.

The third row has precedent in this repository rather than being proposed on faith. Descent.Vtt.Geometry.Interop consumes the Rust descent-geometry-c-abi cdylib across a stable C ABI using [LibraryImport], and that boundary already survives Native AOT in CI. NativeLibrary.Load is the runtime-resolved form of the same seam. It is listed third and marked rare on purpose: it shares an address space, so its fault is the host’s fault, and it should be reached for only where the IPC hop is genuinely the bottleneck.

1.6.2 What is forbidden, and where that is caught

A Studio module MUST NOT be a managed assembly loaded at runtime. No Assembly.Load, no AssemblyLoadContext, no System.Reflection.Emit, no runtime proxy generation. Enforcement point: the Native AOT publish gate. These APIs raise IL2026 / IL3050-class trim and AOT analyzer diagnostics, and the module build treats them as errors — exactly as rngkit-ci.yml’s native-aot and trim-analysis jobs already do for Descent.RngKit.

That enforcement point does not exist yet, because Studio has no code, and naming it as though it did would be the defect ADR-045 clause 2 exists to prevent. It is a requirement on the module build when one is written, and §11 records it as an open item.

1.6.3 The IPC contract is a seam, and it is registered as one

The Launchpad-to-module boundary is a P8 swap point: the line along which a workspace can be replaced, rewritten in another language, or removed. Its exit cost is recorded now rather than worked out later — see upgrade-and-supersession.md §4.

Recommended shape, with the reasoning rather than the preference: length-prefixed messages over stdio or a local socket, carrying the wire format the platform already uses. Reusing the existing serialisation is the point — the protocol module is already built for both hosts, so hand-rolled framing would introduce a second wire format into a product that has one. Whether a given message is FlatBuffers or MessagePack follows §8.2’s existing split and is not re-decided here.

Two costs, stated rather than absorbed:

  • A process hop is not free, and a bake that streamed progress at high frequency would feel it. The mitigation is inherent rather than added: progress is a report, not a stream of geometry. The mesh stays in the module; only the result crosses.
  • Debuggability gets worse. A crash in a child process is harder to diagnose than an in-process exception, so a module must write a diagnosable record on the way down rather than relying on the host to observe its death.

1.6.4 What this rejects, and why each will be re-proposed

  • A managed plugin DLL via AssemblyLoadContext. Impossible under Native AOT. It will be re-proposed by anyone arriving from a JIT .NET background, and the answer is not “we prefer not to” — it is that the artefact cannot execute.
  • Abandoning Native AOT to get plugins back. Rejected: §2 takes AOT for no-runtime-install and startup, and §10’s posture is AOT throughout. Trading the product’s whole deployment model for a plugin mechanism inverts the cost.
  • One monolithic binary with every workspace compiled in. Rejected — it is precisely what §1.5 exists to prevent, and it makes every module update a full redownload.
  • Wrapping compute modules as WASM inside the WebView. Rejected for the heavy case, and it is closer than it looks: it solves distribution and sandboxing, and the client already ships a QuickJS/WASM worker. But a million-triangle decimation wants many-core native threads and memory beyond a WASM heap, which is exactly the workload this section exists to place. Reasonable for a future light module; wrong for the Asset Manager.

2. Technical stack

LayerChoiceRationale
ShellPhotino — OS-native WebView (WebView2 / WebKit) in a Native AOT .NET host100% UI reuse of the VTT’s SolidJS + Babylon.js + Monaco + Rete.js stack, with zero-latency access to the local C# layer. Electron was rejected on binary size and memory; a separate Avalonia UI was rejected because it forks the design system
Runtime.NET 10, Native AOT, self-contained per-platform binaryNo runtime install; consistent with §10’s AOT posture
GeometryDescent.Geometry — the same Rust crate, hosted natively over the C ABIADR-017’s determinism guarantee is only worth having if the authoring host computes the same answers as the silo. Studio links the same crate, not a port
Server-logic sandboxDescent.Sandbox (Jint) — the same library the silo hostsSame reason
Client-logic sandboxThe PluginWorker — the same worker build the VTT client ships. Not QuickJS since ADR-144: the guest is the same wasm32-unknown-unknown payload the silo runs, on the same three-name ABISame reason, and now stronger than “the same worker”: it is the same artefact, so Studio’s pre-flight runs the bytes the silo will run

The stack choice is load-bearing for one property and decorative for the rest. Reusing the identical geometry crate and sandbox libraries is what lets Studio’s pre-flight results predict production behaviour at all. Photino, AOT and binary size are ordinary engineering preferences.


3. The asset forge

Where this runs, since ADR-109. Everything in §3 is the Asset Manager workspace (§1.5.1) — a separately-installed, separately-executed module, not a panel in the editor. §3 describes what the forge does and is unchanged; §1.5 decides where it runs, and the reason is that a decimation pass is unbounded in time and very large in memory. Read the two together before implementing either.

3.1 Import and optimisation

STL and high-poly GLB import, decimation, remeshing, UV unwrapping, and normal-map baking run locally against native tooling. Output enters the existing §6.2 pipeline unchanged: KTX2 / Basis Universal supercompression, collision mesh, occluder mesh, NavMesh nodes, and the Profile C top-down 2D tile (ADR-005).

Two constraints inherited from §6.1 that a local baker routinely gets wrong:

  • KTX2 is a supercompressed intermediate, not a GPU format. Every texture is transcoded on the CPU into BC7 / ASTC / ETC before upload, and zstd payloads are additionally inflated. This is tens of milliseconds for a 4K texture and is budgeted in §9.6.1. Studio must show the creator the transcode cost on the target profile, not the file size, because file size is the number that looks like the answer and is not.
  • VRAM is the binding constraint on large maps, not disk or heap. §6.1 requires a per-profile resident VRAM budget that drops LOD rather than failing an allocation. Studio’s map validator reports against that budget; a map that fits on the author’s workstation and not on Profile C is otherwise discovered from user complaints.

3.2 Procedural generation

Generation runs locally and exports its output, never its logic: a static Map Definition (JSON) plus a pre-computed NavMesh. Load time in the VTT is therefore independent of generation cost, and the generator itself never executes on the platform.

This is the cleanest instance of the Studio thesis. A hundred-room dungeon costs the creator’s CPU once and the platform nothing, forever.

3.3 Extraction remains GM-confirmed

Studio may run §6.3’s assisted 2D-to-3D extrusion locally. It does not inherit authority to promote the result. §6.3’s rule stands unchanged and for its stated reason: an unconfirmed map is fully Unexplored to non-GM viewers and refuses token placement, because a wall is simultaneously a collision volume and an LOS occluder, and “no collision” means “the whole map is revealed”. A locally-extracted map arrives as a proposal with its confidence metrics attached.


4. The code lab

4.1 The tier boundary, stated against §6.4

The prior draft described a two-way split — C# Cartridges versus “TS/Flow scripts in the Jint sandbox at 50 ms”. Both halves are wrong against this corpus. §6.4’s tier table is normative and Studio targets three of its rows:

§6.4 tierMechanismStudio’s roleWho may publish
P0 — Privileged.NET cartridge via AssemblyLoadContextProject scaffolding, local debug hostFirst-party and signed, code-reviewed partners only
P1 — Sandboxed server logicJint via Descent.SandboxAuthoring, local pre-flight under real budgetsAny creator
P2 — Sandboxed client logicQuickJS/WASM PluginWorkerAuthoring, local pre-flight under the Profile C frame budgetAny creator
P3 — Data onlyJSON AST UI descriptors, asset bundlesThe WYSIWYG builder’s outputAny creator

P1 and P2 are different runtimes with different budgets and are not interchangeable. A script authored against server-side seams cannot be moved to the client worker by changing a manifest field, and Studio must refuse to let a creator try.

4.2 The budgets are Q-017, and they are not 50 ms

The prior draft repeated a “50 ms execution limit” throughout. 50 ms is the tick period (Q-001, 20 Hz), not a script budget, and §4.3 rejects that equivalence explicitly: a per-execution limit equal to the tick would let one script consume 100% of a room’s budget and ten scripts produce half a second of head-of-line blocking. The normative values are:

QuantityMeaningValue
Q-017SCRIPT_CPU_SOFT_MS / SCRIPT_CPU_HARD_MS5 / 20
Q-017SCRIPT_HEAP_MB10
Q-017SCRIPT_AGGREGATE_MS25

Q-017 is host runtime configuration and must not be compiled into Studio as a constant (§3.1). Studio fetches the current values from the ingestion API and pins them per project; a Studio build that hardcodes them will silently pass scripts that production rejects after the next tuning change — which is the failure mode where the creator’s tool tells them the truth about last quarter.

For P2, the governing bounds are ADR-040’s per-frame plugin budget and PLUGIN_FRAME_AGGREGATE_FRACTION (Q-026, pending). Studio’s dev host can impose the Profile C budget deliberately, because a plugin that is fine on the author’s desktop and plugin:deferred on a tablet is otherwise only discovered from user complaints.

4.3 The visual editor enforces statelessness by construction

Rete.js topology with SolidJS fine-grained rendering — no React, and dragging mutates style.transform only, so there is no VDOM layout thrashing (§6.4). The paradigm is “Event In → Mutations Out”:

  1. Every graph starts at a global Event Root node, which supplies the immutable starting context.
  2. No persistent variable nodes. State is read through Read Entity Tag and passed along wires. A creator cannot express a variable that survives an event.
  3. Graphs terminate at Mutation Request sinks. A script proposes; it does not act.
  4. Collapse-to-node subgraphs are pure helpers and may never be Event Roots.

This is the most valuable idea in the extended design and it is carried forward unchanged. It makes §4.3’s pure-read seam and the MutationProposal contract into properties a creator cannot violate rather than rules they must be taught — the creator believes they made a trap explode; structurally they authored a Zero-Trust CQRS command. Compilation targets a stateless function returning a mutation array.

Code export is one-way. Transpiling the AST to TypeScript in Monaco is always available; hand-editing the result makes the asset code-only, because arbitrary closures do not reverse into nodes. Stated so the limitation is a documented property rather than a bug report.

4.4 Namespacing, and its relationship to ADR-095

All tags and variables are prefixed with the creator’s globally unique package id — the tag boss compiles to @blood-studios/vampire-pack:boss. Tokens are collections of tags and do not hard-depend on scripts: importing a Vampire token without the Vampire script yields a working 3D model with no special abilities.

This composes directly with ADR-095’s prefix families, and the composition should be made deliberately rather than discovered. ADR-095 permits a declaration to open a prefix family so player-invented specialties are writable without being enumerable, and records the cost: the tombstone weakens inside a live family. A package-id prefix is a family whose members are creator-invented, so the same cost applies, for the same reason — a misspelt tag inside a live package becomes a new tag rather than a refusal. Q-077’s 64-byte key bound applies to the fully-qualified key, and Studio must validate against the qualified length, not the authored suffix.

4.5 Composition between creators

  • Static reference — pure helper import (@marketplace/math-utils), bundled into the same sandbox at runtime.
  • Dynamic reference — a script emits a mutation request that another script, listening, modifies. This is how mods stack without hard-coded dependencies.

Ordering is a determinism surface (ADR-017). Creators declare an execution phase, and where phases tie, the GM resolves the order. The resolution must be recorded as an event on the room’s stream, not held as UI state: replay must reproduce the same order, and a tie broken differently on replay silently produces a different game. The prior draft described the drag-and-drop resolution UI without saying where the answer lives, which is the part that matters.

4.6 Local pre-flight, and what it is worth

Studio runs the real Jint and QuickJS engines under the real budgets and reports failures at authoring time. This is npm test on a laptop: fast feedback, zero assurance. §6’s ingestion gate is where the answer counts.


5. The entity model

5.1 Narrative hierarchy over spatial hierarchy

The outliner organises by narrative time (Act 1: Tavern Brawl) rather than by folder tree. A Scene is a small JSON array of pointers — entity UUIDs, positions, tag overrides — and duplicates no mesh data. A separate global Asset Palette holds unassigned tokens, maps and prefabs.

5.2 Persistent versus instanced entities

  • Instanced — generic assets; every drag spawns a fresh copy at full health.
  • Persistent — a global UUID whose state survives despawn. A boss that took 50 damage in Act 1 returns in Act 9 with 50 damage taken.

Persistent state is campaign-scoped and lives in the event-sourced store like any other state (§7). It is not a Studio-local file, because a Studio-local copy of authoritative state would be a second source of truth for the same field.

5.3 Kitbashing and the virtual hierarchy

Physical grouping — a wagon of ten planks, a goblin holding a sword — is a TransformParent ECS component. The outliner stays flat, but an entity with children renders a > disclosure that reveals them, which is flat data with nested presentation rather than a second hierarchy.

5.4 Director Mode

The narrative hierarchy is training wheels, not a rail. When players derail the plot, the GM drops an empty map, drags persistent entities from the palette, and continues. Nothing in the scene model requires the pre-authored path to be followed.

5.5 Scale, stated against the budgets

The extended design claimed “tens of thousands of tokens without crashing the browser or server” on the strength of WebGPU instancing. Instancing bounds draw calls, which is one of at least four binding constraints, and the claim is withdrawn as stated. The others are §5.2’s 28 ms tick occupancy, the per-viewer snapshot assembly cost, Q-027’s interest-set bound (pending), and §6.1’s resident VRAM budget. Instancing is real and valuable; it is not a capacity argument on its own, and a capacity number belongs in Quantity_Registry.md under measurement (§9.1’s rule that a benchmark asserts orders of magnitude, never tight bounds).


6. Publishing: the ingestion gate

Studio is the publishing client. The gate is a CI/CD pipeline and it assumes nothing about the client that fed it.

  1. Local pre-flight — creator-facing, advisory (§4.6).
  2. Signing — the bundle and AST are signed with the creator’s key. This establishes provenance, not safety: it says who submitted, not that what was submitted is sound.
  3. Server-side re-verification — a sterile worker re-runs every check under the real Q-017 budgets and a fuzzing suite that bombards the script with adversarial events. Exceeding budget, looping, or crashing rejects the upload.
  4. Static admission — the JSON AST is validated against the published schema, and §12.2.2’s target validation applies (a ui.TextField on a Token target is refused). Fully-qualified tag keys are checked against Q-077.
  5. Publication — only then does the Marketplace apply entitlement and push to CDN.

Publishing is Studio’s, exclusively (ADR-123). The VTT play client contains no publishing pathway at all — its Personal Vault is for running games, and an asset leaves it for the Marketplace only through this flow. That is a structural boundary rather than a confirmation dialog: a path that does not exist needs no ceremony to guard, and the signing material this workspace already holds (§1.5.1) is the reason the flow belongs here rather than in a client whose job is play.

Step 0 — the AI disclosure, stated before the creator commits (ADR-122 clause 6). The publish flow presents, as a term the creator accepts rather than a link they may open:

Your content is embedded (vectorised) to enable in-game AI features for buyers. We guarantee it will NEVER be used for model training.

Both halves are load-bearing and neither may be dropped. The first is a disclosure the architecture makes necessary — §7.2 of the VTT whitepaper embeds campaign-derived content by design, and a creator discovering that after publishing would be entitled to feel misled. The second is a commitment the platform can keep: there is no training pipeline, an architecture test asserts no path into one, and third-party providers are held to it by the release-checklist term ADR-122 requires. Stating only the guarantee would be the overclaim; stating only the disclosure would waste a real assurance.

Steps 3 and 4 are the trust boundary. Steps 1 and 2 are convenience and attribution. An architecture that treats signing as safety has confused authentication with authorisation — a signed bundle from a real creator whose script loops forever is exactly the case the fuzzer exists to catch.

6.1 Versioning and campaign safety

Strict SemVer with a lockfile per room, and immutable releases: a published version is never overwritten or deleted, so an old campaign cannot break because an upstream creator shipped. Major versions do not auto-update into running campaigns; patches may hot-reload.

This is ADR-055’s mechanism, and the join must be explicit. ADR-055 governs room-level cartridge-set compatibility admission; a Marketplace version pin that disagreed with ADR-055’s admission would produce a room that owns content it may not load. The lockfile is an input to ADR-055’s admission, not a parallel authority.

6.2 Bundles and granular import

A bundle is one SKU containing maps, tokens, scripts and UI ASTs. On import the GM gets a checklist and may take a token without its script — data does not hard-depend on logic (§4.4). Checkboxes grey out only for hard logic dependencies (script → script), which is the only case where partial import produces a broken graph rather than a plain asset.

6.3 Localisation

All scripts and UI ASTs use i18n string tables rather than embedded flavour text. Marketplace-side machine translation generates localised tables at publish time. The string table is data (P3) and is validated as data; a translation pipeline that emitted anything other than strings into a schema-validated descriptor would be a code path into tier P3, which the tier table forbids.


7. Collaboration

7.1 The hybrid model

Default state is local and offline-first, versioned by ordinary Git. Because projects are directories of JSON and TypeScript, they work with GitHub or GitLab natively — branches, forks, pull requests, and a descent-marketplace-publish CI action for publisher teams.

Git for logic, CDN for assets. Repositories hold TS, JSON ASTs and scene files containing CDN pointers; .glb and texture binaries upload to the asset CDN. A repository that accumulated 3D binaries would be unusable within a season.

7.2 “Go Live”, and the bound it needs

Clicking Go Live pushes local project state to a temporary StudioSandboxGrain, and invited collaborators edit in real time.

Two constraints the extended design did not state, and which are the difference between this feature and a tick regression:

  • StudioSandboxGrain is a distinct grain type with its own placement and its own budget. It is an authoring workload — bursty, human-paced, tolerant of tens of milliseconds — and must not be placed or budgeted as a RoomGrain. §5.2’s occupancy table describes the play-time tick and does not cover it; co-placing an authoring session with live rooms under the ADR-002 weighted placement director requires a published room weight for this grain type, or the director is admitting an unmeasured load.
  • Sync is explicit. The cloud sandbox does not auto-commit. Collaborators click “Sync to Local”, review the Git diff, and push a clean commit. Auto-commit was rejected because it produces a history nobody can bisect.

8. Immersive audio, corrected

Scripts may attach audio sources to coordinates or entities, and may target a single player — the horror and secret-communication mechanics this enables are genuinely novel and are kept.

What changes is where the filtering happens. Audibility is computed server-side and passes through §8.2’s per-viewer filter like any other disclosure. The client receives pre-attenuated parameters positioned at the aperture through which the sound arrives, and is never told the true source position. A design in which a script emits PlayAudio(x, y, z) and the client attenuates by distance is a through-wall position oracle for every entity that makes a sound: the attenuation curve inverts to a distance, and three sources trilaterate. This is the §8.2 asymmetry applied to a second sense, and it is exactly the class of leak ADR-034 rejected client-side culling to prevent.

Targeted whispers are unaffected — a whisper routed to one player is a disclosure decision the server already makes, and it is made in the same place.


9. Rejected from the extended design

Recorded so it is not re-proposed. Each entry names what was claimed, what it conflicts with, and what survives.

#Claim in the extended designDisposition
1“50 ms sandbox execution limit”, repeated throughoutRejected — factually wrong. 50 ms is Q-001’s tick period. §4.3 rejects a per-execution limit equal to the tick by name. Real values are Q-017 (5 / 20 / 10 / 25) and must be fetched, not compiled in (§4.2)
2“Scripts run in the Jint Sandbox” as the single script tierRejected as incomplete. Conflates §6.4’s P1 (server, Jint) with P2 (client, QuickJS/WASM PluginWorker). Different runtimes, different budgets, not interchangeable (§4.1)
3DRM “renders 99.9% of ripping tools useless”; “the raw .glb never exists on the user’s hard drive”Rejected as an overclaim that contradicts §6.1’s Honest Threat Model. The key must reach WebCrypto and the plaintext must reach writeTexture; a modified client or hooked crypto.subtle recovers the asset. The mechanism raises the cost of casual redistribution and is kept on that basis. Marketing language must match
4“Orleans transmits the decryption key via SignalR” on ownership verificationRejected — contradicts ADR-060. Keys are exchanged per session against a short-lived token and are never persisted; OPFS stores ciphertext only; revocation is implemented by ceasing to issue keys. A key delivered on ownership check and cached alongside the ciphertext makes a terminated licence unenforceable
5“Zero-Copy Memory Mapping” of decrypted vertex data straight into WebGPU buffersRejected as specified. §6.1 requires a CPU transcode step (KTX2 → BC7/ASTC/ETC, plus zstd inflate) that is tens of milliseconds for a 4K texture and is budgeted in §9.6.1. “Zero-copy” describes a path that does not exist for textures. Streaming through OPFS with FileSystemSyncAccessHandle — the real mechanism — is kept
6Two-Tier Orleans topology (sterile front-end silos without DB credentials; back-end storage silos)Rejected as specified — conflicts with ADR-002, which states the Modular Monolith is “a single deployable scaled horizontally, not a microservice fleet”. The underlying concern is legitimate — blast radius of a sandbox escape — and is logged as OI-V-02, requiring an ADR that amends ADR-002 on its own merits. It must not arrive as a side effect of a Studio document
7“Tens of thousands of tokens without crashing” on WebGPU instancingRejected as a capacity claim. Instancing bounds draw calls; §5.2 occupancy, snapshot assembly, Q-027 and the VRAM budget bind independently (§5.5). A capacity number belongs in the registry under measurement
8Spatial audio as client-side attenuation of script-supplied coordinatesRejected — a through-wall position oracle. Corrected in §8: audibility is server-computed and pre-attenuated at the aperture. The feature survives; the topology changes
9“Mathematically impossible” / “completely eliminates” / “guarantees 100%” phrasing throughoutRejected as a class. §14 and the corpus convention require honest threat models. Where a property is structural, it is stated as structural and its cost is named; where it is probabilistic, it is not described as a guarantee
10Twitch audience actions triggering sandbox events (Broadcaster tier)Not rejected — unclassified, and therefore blocked. External actors causing state mutations is a boundary class §6.4’s zero-trust inventory covers (requests we make on someone else’s instruction, and data entering). It needs a tier assignment, an authenticated principal, and a rate bound before it is scoped. Logged as OI-V-03
11Headless 4K/60 cinematic rendering on the server (Broadcaster tier)Deferred, not rejected. GPU-bearing always-on compute inverts §10’s FinOps posture and every scale-to-zero argument in the corpus. It is a business decision with a large infrastructure consequence and needs its own cost model before it is architecture
12BYOK — creator’s own LLM API key in StudioAccepted with a constraint. The key stays on the creator’s machine and requests go directly from Studio to the provider; the key must never transit or be stored by platform infrastructure. A platform-proxied BYOK key is a credential the platform is now custodian of, which is a materially different obligation
13GBGC’s “C# BFF strips card data before sending to non-owners”Rejected as a location error. There is no BFF on the VTT path; per-viewer filtering runs inside the room’s turn before serialisation (§8.2). The mechanism is right and the layer is wrong — see Ecosystem_Module_Designs.md §2

10. ADR-098 — Descent Studio Is an Untrusted Producer; the Ingestion Gate Is the Trust Boundary

Status: Accepted · Date: 2026-08-05 · Depends-on: 010 (JSON AST delivery), 040 (plugin budgets) · interacts with 055, 060, 095

Context

Descent Studio executes on hardware the creator controls. It enforces Q-017 budgets, validates AST schemas, checks tag lengths and runs fuzzing locally. Every one of those checks can be removed by patching the binary, and the ingestion API can be called without Studio at all.

The extended design recognised this for CPU limits — “a malicious user could reverse-engineer the API and upload raw JSON” — and then, in the same document, treated cryptographic signing as though it established safety and described Studio’s local budget enforcement as “ensuring that a script will never crash in production”. The argument was made once and not generalised, which is the more dangerous shape: a corpus that states a boundary in one place and relies on its absence in another.

Decision

  1. No platform-side property may be established by Studio. Any check whose result the platform depends on is re-run server-side, on the submitted artifact, under the authoritative quantities.
  2. Signing establishes provenance only. It answers who submitted this, never is this sound. Documentation, UI copy and marketing may not describe signing as a safety property.
  3. Q-017 and other host runtime configuration are fetched by Studio, never compiled in (§3.1 already forbids these being open-source constants). A pinned copy per project is permitted for offline work and is re-validated at ingestion.
  4. The ingestion gate re-runs the fuzzing suite and the schema and target validation on every submission, including submissions that arrive with a valid signature from a creator in good standing.
  5. Studio’s local pre-flight must present itself as advisory. A UI that says “passed” where the platform will re-decide trains creators to treat a local green as a publication guarantee, and produces the support burden this ADR exists to avoid.

Alternatives Considered and Why Rejected

  • Trust signed submissions from verified publishers and skip re-verification. Rejected: it makes the signing key a bypass of every resource bound in §4.3, so key theft escalates from impersonation to arbitrary load on every room that installs the package. The fuzzing cost is per-publish and small; the bypass is permanent.
  • Attest the Studio binary and trust attested builds. Rejected: client attestation establishes which binary ran, not what it was fed, and it cannot cover the direct-API path that must remain open for CI publishing (§7.1). It would add a substantial mechanism that does not remove a single server-side check.
  • Drop local pre-flight since it provides no assurance. Rejected: it provides fast feedback, which is the entire reason creators will use Studio rather than the API. Its value is developer experience, and clause 5 makes that honest rather than removing it.

Consequences (including negative)

  • Every check is implemented twice — once in Studio for feedback, once at ingestion for truth — and the two can drift. Drift is bounded by clause 3 for quantities and by sharing the same validator library where the language permits; where it cannot be shared, the divergence is a known maintenance cost and is stated rather than hidden.
  • Publishing has latency the creator will notice, because the fuzzing suite runs before listing. This is accepted; the alternative is publishing scripts that fail in live rooms.
  • Clause 5 makes Studio’s own UI less reassuring than a competitor’s. That is the correct trade for a platform whose sandbox is load-bearing.

Enforcement

  • The ingestion pipeline re-runs budget, fuzzing, schema and target validation unconditionally; a test asserts that no submission path can set a flag skipping them.
  • A test asserts the ingestion API rejects a well-formed, correctly-signed bundle whose script exceeds Q-017, because the property under test is that the signature does not matter.
  • Studio’s budget values are read from the ingestion API at project open; a build-time check fails if a Q- value appears as a literal in the Studio source tree.
  • Q-079 bounds fully-qualified tag keys at ingestion against Q-077’s 64 bytes, measured on the qualified form (§4.4).

§11 Index Line

ADR-098: Descent Studio is a local fat client and an untrusted producer — every check it runs is advisory and is re-run server-side at ingestion under authoritative quantities, because a binary the creator controls can be patched and the ingestion API can be called without it; cryptographic signing establishes provenance and never safety, so a correctly-signed bundle exceeding Q-017 is still rejected; host runtime quantities are fetched rather than compiled into the client so a Studio build cannot certify against last quarter's limits; trusting attested builds or verified publishers was rejected because it converts key theft into a permanent bypass of every §4.3 resource bound; and local pre-flight is presented as advisory rather than as a publication guarantee.


10.1 ADR-109 — Studio Is a Launchpad Plus Independently-Installed Workspaces; a Module Is Never a Managed Assembly

Status: Accepted · Date: 2026-08-06 · Depends-on 098 (a module boundary is a producer-side structure, and the ingestion gate remains the only trust boundary) · interacts with 017, 071

Context

§1.5 and §1.6 set out the design; this records the decision and the alternatives, because the central one will be re-proposed by anybody who has shipped a .NET plugin system before.

Studio’s three domains have almost nothing in common at runtime. World Builder is interactive and latency-sensitive; the Cinematic Replay Editor consumes an archive it did not author; the Asset Manager runs unbounded decimation over million-triangle meshes and holds the creator’s signing material. Shipping them as one binary makes every creator download all three, makes every update a full redownload, and puts an OOM-capable bake in the same process as unsaved map edits.

The constraint that shapes the answer is that Native AOT has no JIT and no IL loader. Assembly.Load, AssemblyLoadContext and System.Reflection.Emit are unavailable — not slow, not discouraged, unavailable. Every conventional .NET plugin architecture is built on them.

Decision

  1. Studio boots into a Launchpad, which is small, always installed, and functional with zero workspaces present. A workspace is selected and the UI transitions wholly into it.
  2. Three workspaces are defined: World Builder, Cinematic Replay Editor, Asset Manager. Asset baking and Commercial Bundle packaging live in the Asset Manager and not in the editor, so that unbounded compute and signing material are both outside the window holding unsaved work.
  3. A module is one of exactly three artefact kinds (§1.6.1): a signed web bundle loaded by the WebView, a self-contained Native AOT executable run as a child process over a versioned IPC contract, or — rarely — a C-ABI shared library via NativeLibrary.Load. A module is never a managed assembly loaded at runtime.
  4. Modules are independently downloadable and independently updatable, gated by a declared host-contract version range. A module outside the range does not load and says which side is stale.
  5. A module is signature-verified before it is executed or loaded. ADR-098 protects the platform from the creator; this protects the creator from a substituted module, and the two directions are separate.
  6. The Launchpad-to-module IPC contract is a P8 seam and is registered as one.

Alternatives Considered and Why Rejected

  • A managed plugin DLL via AssemblyLoadContext. The conventional answer, and it is not available: Native AOT compiles ahead of time to native code with no IL loader. Recorded first because it is the alternative most likely to be re-proposed, and the reason is a hard capability gap rather than a preference.
  • Abandon Native AOT so plugins work. Rejected: §2 takes AOT for no-runtime-install and startup, and §10’s platform posture is AOT throughout. This trades the product’s entire deployment model for a loading mechanism, when three AOT-legal mechanisms already exist.
  • One monolithic binary containing every workspace. Rejected: it is what §1.5 exists to prevent. Every creator pays for every domain, and every update is a full redownload.
  • In-process COM/NativeLibrary for the heavy workspaces too. Rejected for the Asset Manager specifically. It is AOT-legal, so the rejection is not about the constraint — a shared address space means a bake that exhausts memory takes the editor with it, and cancellation stops being “kill the process”.
  • Compute modules as WASM inside the WebView. Rejected for the heavy case only, and it was the closest alternative: it solves distribution and sandboxing, and the client already ships a QuickJS/WASM worker. A million-triangle decimation wants many-core native threads and memory beyond a WASM heap. Explicitly left open for future light modules.
  • Scripting the modules instead (a plugin API in JS). Rejected as a category error: the problem is shipping native compute, and a script host does not remove the need for the code the script would call.

Consequences (including negative)

  • A process hop appears on every heavy operation. Mitigated by the boundary’s shape rather than by tuning — progress is a report, geometry stays in the module — but it is a real cost and a chatty future module will pay it.
  • Debuggability gets worse. A child-process crash is harder to diagnose than an exception, so a module must write a diagnosable record on the way down.
  • Three artefact kinds means three build and signing pipelines, not one. This is the largest ongoing cost of the decision and it is accepted for the download and isolation properties.
  • A version-range contract can strand a creator whose module is outside the window. Q-083 exists to make that window a stated number rather than an emergent one.
  • The base install gets a second failure mode: a workspace that will not install. The Launchpad must remain usable in that state, which is why decision 1 requires it to work with zero workspaces.

Rights-holders (ADR-079)

None. No new class of data is retained. The Asset Manager concentrates the creator’s existing signing material rather than introducing any.

Enforcement

  • The managed-assembly prohibition: the Native AOT publish gate. Assembly.Load, AssemblyLoadContext and Reflection.Emit raise IL2026 / IL3050-class diagnostics, and the module build treats them as errors — the pattern rngkit-ci.yml already runs as native-aot plus trim-analysis. This gate does not exist yet, because Studio has no code; it is a requirement on the module build and is recorded in §11 as an open item rather than claimed as present.
  • The contract-version range: a Launchpad startup check, refusing to load an out-of-range module. A runtime refusal, not a build check, because the module and host are built separately by construction.
  • Signature verification: a Launchpad startup check before execution or load.
  • The seam: a row in upgrade-and-supersession.md §4, which P8 makes a defect to omit.

§11 Index Line

ADR-109: Descent Studio boots into a small Launchpad and workspaces are installed on demand and updated independently — World Builder, Cinematic Replay Editor and Asset Manager, with asset baking and Commercial Bundle packaging in the Asset Manager so unbounded compute and signing material sit outside the window holding unsaved work; a module is a signed web bundle loaded by the WebView, a self-contained Native AOT executable run as a child process over a versioned IPC seam, or rarely a C-ABI shared library via NativeLibrary.Load, and is NEVER a managed assembly loaded at runtime because Native AOT has no JIT and no IL loader; a managed plugin DLL was rejected as impossible rather than undesirable, abandoning AOT to regain plugins was rejected as inverting the cost, and in-process loading was rejected for the Asset Manager because a shared address space makes an out-of-memory bake take the editor with it; enforced by the Native AOT publish gate, a Launchpad contract-range check and signature verification, with the module contract window as Q-083. (Depends-on 098.)


11. Open items this document creates

IdItemBlocked on
OI-V-02Blast-radius containment for a sandbox escape (the legitimate concern behind the rejected two-tier silo topology)An ADR amending ADR-002 on its own merits, with a cost model for the second deployment
OI-V-03Tier classification, principal model and rate bound for external audience-triggered events (Broadcaster tier)§6.4 zero-trust inventory pass; product decision on whether the tier ships
Q-079Fully-qualified tag key bound at ingestion, measured against Q-077 on the qualified formNothing — implementable now
Q-080StudioSandboxGrain published room weight for the ADR-002 placement directorMeasurement of an authoring session’s occupancy
OI-V-04The Native AOT publish gate that enforces ADR-109’s managed-assembly prohibitionClosed 2026-08-11 (ADR-152). studio-ci.yml’s native-aot job publishes both Studio executables with IlcTreatWarningsAsErrors and the three analysers on, then runs the published binary with --check. Both halves matter: the publish proves the graph compiles, and --check proves it starts, discovers its modules and verifies their signatures — a publish that succeeds and a binary that runs are different facts
Q-083STUDIO_MODULE_CONTRACT_WINDOW_RELEASES — how many releases a module contract stays loadableA product decision on update cadence, not measurement
OI-V-06The Ruleset Forge’s publish gate — the engine probe of ADR-147 wired into the workspace’s own build, so a non-conforming cartridge cannot be published from StudioNarrowed 2026-08-11 (ADR-152), not closed. The workspace exists and drives the pipeline including --engine-probe, so a creator can run it. What is still owed is the publish half: nothing forces the probe before an artefact leaves Studio, because there is no publish path out of Studio yet. The gate is a checkbox in the Forge’s UI, which is a prompt rather than a gate