Architecture Decision Rulings — R34
Architecture Decision Rulings — R34
Date: 2026-08-10 Scope: three technology upgrades proposed together as “the 2026 modernization” — a WebAssembly sandbox runtime, WebTransport over HTTP/3, and Bun for the frontend pipelines.
Three decisions: ADR-137 (a WebAssembly engine joins Descent.Sandbox beside Jint),
ADR-138 (WebTransport is declined, with two of ADR-029’s three blockers now settled
negative), ADR-139 (Bun is declined as installer and build tool, and the BFF’s runtime
question is handed back to its owner rather than answered here).
One of the three says yes. That is worth stating at the top, because
upgrade-and-supersession.md §5 warns that an
ADR which only ever says yes is advocacy, and a file containing three refusals would be the
opposite failure. Each was evaluated on its own evidence and they came out differently:
| Proposal | Outcome | What decided it |
|---|---|---|
| Server-side WASM | Adopted, additively | Measured capabilities Jint does not have — chiefly that stack exhaustion traps instead of killing the host |
| WebTransport / HTTP/3 | Declined, with a trigger | Two blockers verified still real: SignalR has no WebTransport transport, and the deployment target has no UDP ingress at all |
| Bun | Declined for tooling; not decided for the BFF | Q-M-013 (measured, 8 969 ms) and §9.1’s min-replica-1 BFF between them refute the cold-start thesis; and Bun’s default installer layout destroys ADR-110’s property |
A note on the premises, because two of the three were factually wrong and saying so is part of the record (
P9). The proposal described the frontend pipelines as being on “Node/npm”: they have installed with pnpm since ADR-110 (2026-08-08), and npm is refused by apreinstallguard. It also asked that “all Dockerfiles” move tooven/bun: there are no deployment Dockerfiles for either frontend. The only one in the frontend tree isDockerfile.e2e, whose base image ismcr.microsoft.com/playwrightand is chosen for the browser rather than for the JavaScript runtime — replacing it withoven/bunwould remove Chromium from the image that exists to hold Chromium. Neither error changes the question, which is why both proposals were still evaluated rather than returned.
ADR-137 — A WebAssembly engine joins Descent.Sandbox, and Jint is not retired
Status: Accepted Date: 2026-08-10
Context
The proposal was to replace Jint in core/Descent.Sandbox with a server-side WebAssembly
runtime: Studio would compile creator scripts to .wasm, and the silo would execute only those.
The replacement half does not survive contact with what the sandbox is for. ISandboxEngine
takes JavaScript source, and ARCHITECTURE.md §1 says why: what it runs is “attack roll macros
written by the Keeper (KP), workshop custom modules, map trigger traps”. A Keeper typing a macro
into the client mid-session has no compiler, and there is no build step available to insert one at
that table. A WebAssembly-only sandbox cannot run a macro that does not exist until the moment it
is submitted.
That makes this a kind C conflict under
upgrade-and-supersession.md §2 — the new
technology cannot satisfy a property the architecture depends on — and §2’s instruction for a kind C
is an ADR plus a cascade check, not a swap. The property is ad-hoc, uncompiled,
player-authored execution at session time, and nothing in the proposal replaces it.
So the question became the useful one: what does a WebAssembly runtime do better, for the payloads that genuinely are compiled? That was measured rather than reasoned about.
What was measured
On win-x64, .NET 10, Wasmtime 44.0.0 (the wasmtime-dotnet binding), 2026-08-10. Every row was
observed; the probe is not retained, so the numbers are recorded here rather than cited to a file.
| Claim | Evidence |
|---|---|
| Stack exhaustion is an ordinary trap | A guest recursing to a depth of 50 000 000 trapped StackOverflow, and a fresh store on the same engine then executed normally. Held at 128 KB, 512 KB and 1792 KB max_wasm_stack, on a default-stack thread and a 16 MB one |
| Fuel is deterministic | A self-recursive counter consumed exactly 10n + 5 fuel at n = 1 000 and n = 10 000 |
An infinite loop traps OutOfFuel | (loop $l br $l) trapped in 3 ms at 100 000 fuel |
Epoch interruption traps Interrupt | Trapped 67 ms after a timer thread called IncrementEpoch() |
| The epoch counter is engine-global | One increment expired a deadline set on an unrelated store |
SetEpochDeadline is relative, and enabling interruption without setting one traps immediately | A zero deadline is “already expired”, not “no limit” — the first guest call trapped Interrupt |
| A linear-memory ceiling is enforced at the growing instruction | memory.grow past the cap returned -1 to the guest; no host exception |
| A compiled module can be weighed rather than estimated | Module.Serialize() returned 14 416 bytes for a four-function module |
An invalid Config aborts the process | Two combinations panicked in crates\c-api\src\engine.rs: disabling SIMD while relaxed-SIMD stays enabled, and max_wasm_stack above the async stack size |
The first row is the decision. SandboxOptions.MaxRecursionDepth is hard-capped at 50, and its
own documentation says why: above it “some constructs exhaust the native stack before Jint’s counter
trips, and the resulting StackOverflowException is uncatchable”. An uncatchable stack overflow is a
dead silo and every room on it. On the WebAssembly path the same shape is a trap with a result
attached — so it has a test, WasmBudgetTests.ExecuteAsync_WhenPayloadRecursesWithoutABaseCase_ ShouldTrapAndLeaveTheHostAlive, which the Jint path cannot have because the assertion after the
crash would never run.
A correction to an earlier reading in the same session, recorded because it nearly went the
other way: the first probe appeared to show the host dying on deep recursion. It was not dying —
the apphost exits 127 with no output when launched from Git Bash, a native-library resolution
failure. Run through dotnet exec the identical input trapped cleanly. The claim that survives is
the opposite of the one first observed, and P2 is the reason it was checked twice.
Decision
-
A second port,
IWasmSandboxEngine, registered by its ownAddDescentWasmSandbox. Not a widening ofISandboxEngine, because the two take different artefacts: source that may not parse, and bytes that may not be a module. The seam rule is unchanged and now applies twice — registered inInfrastructure, andApplicationandDomainmay type against neither. No wasmtime type appears on the port. -
Separate registration, not one call. ADR-100 clause 5 already ruled on this shape for the Jint pool — “registering a pool with no consumer would build 20 Jint engines at startup for nothing” — and it applies more strongly to a dependency carrying a native library. A host that runs only Keeper macros must not load wasmtime; a host that runs only compiled content must not build a Jint pool. Both may be registered together and share nothing but a
Metername. -
A payload may declare no imports at all, and one that does is refused before instantiation. This is the whole of invariant 1 on this path. The Jint side spends several hundred lines reaching a weaker version of the same guarantee — deep-freezing intrinsics, severing four function constructors, deleting
ShadowRealmand verifying its absence — and it is weaker because a JavaScript realm arrives with capabilities that must be taken away, while a WebAssembly module arrives with none that must be given. Refusing rather than filtering is deliberate: an allow-list of importable names is a host surface to audit on every change, and this engine has no capability to offer yet. -
Invariant 2 is structural rather than performed. A
Storeowns the guest’s entire world and is created and disposed per execution. There is no scrub pass, no microtask drain, no poison audit and no pool, because nothing is reused across tenants. -
ADR-100’s ceiling discipline is carried forward, not relaxed. Ten of
WasmSandboxOptions’ figures have no default and the engine refuses to start without them; three keep defaults on the same argued grounds ADR-100 used — a safety ceiling or a capacity figure rather than a refusal threshold. -
MaxWasmStackBytesis capped in its setter, not inValidate(). This is the one control in the module whose failure mode is not an exception. Amax_wasm_stackabove wasmtime’s async stack size panics inside the Rust c-api and aborts the process, so there is no later point at which the value could still be reported.WasmSandboxOptionsTestsasserts the refusal and deliberately never attempts the invalid construction — a test that tried to observe the runtime’s refusal would take the test host down with it and report nothing. -
Two new
SandboxErrorKindmembers,FuelExhaustedandInvalidModule, with codes and both shipped cultures.FuelExhaustedis kept distinct fromTimeoutbecause the two are different facts and only one is reproducible: a creator told “too slow” retries and it passes, while a creator told “too much work” can count.InvalidModuleis kept distinct fromSyntaxbecause a build artefact has no line number and attaching one would invent a position. -
The guest ABI is three exported names and no imports —
memory,descent_alloc,descent_invoke— with the answer packed into onei64. The host allocates through the guest rather than writing at a fixed offset, because a fixed offset is a location the guest’s own allocator does not know about.
What this costs, and what it does not do
- A payload cannot ask the host anything. The Jint path’s seam functions —
vtt.roll('1d8')— have no analogue. Everything a payload needs must arrive in its input and everything it wants must leave in its output. No clock, no randomness, no identity. That is a real capability loss and it is the price of clause 3. - Nothing here proves a real toolchain’s output satisfies the ABI. Every test fixture is
hand-written WebAssembly text assembled by
Module.ConvertText. That was chosen over checked-in binaries (unreadable in a diff) and over a build step (frontend-ci.ymlalready spends 119 s compilingwasm-pack; a second pipeline should not repeat it), and the limitation is genuine: demonstrating that a Rust-, C- or AssemblyScript-produced module conforms is the first thing Studio’s compile step owes. - Studio does not exist.
docs/studio/Studio_Architecture.mdis spec only. So the producer of the payloads this engine executes is unbuilt, and this engine has no consumer in the silo today — the same state ADR-100 clause 5 ruled deliberate for the Jint pool, for the same reason: this is what makes the eventual wiring safe, not what performs it. - No
Q-IDis introduced. Nothing here is measured against a budget. The fuel and memory figures in the test fixtures are test configuration, not registry quantities, and inventing aQ-IDfor a number nobody has profiled would be a⊙masquerading as a target (P5). - A crafted hash collision on the module cache is not tested, and cannot practically be. The
cache verifies the full payload bytes on every hit, which is what turns a collision into a miss —
the same defence
LruAstCachecarries — but producing anXxHash3collision to prove it is infeasible, so the mechanism is asserted by reading and not by a test. Stated rather than left as an apparent gap in coverage. - Native AOT is unaffected and still unsupported.
IsAotCompatiblewas alreadyfalsefor the AST-level isolation rewriter; the native library adds a second reason.
Alternatives Considered and Why Rejected
-
Replace Jint outright, as proposed. Rejected on the capability above: it removes ad-hoc session-time macro execution, which is the sandbox’s stated mission. This was the proposal and the authorisation was explicit, so declining half of it is recorded as a finding rather than as a preference — the half that is buildable was built in full.
-
Tear down ADR-100’s Jint ceilings, which the proposal explicitly authorised. Not done, and this is the one place this ruling declines a granted permission. Those seven ceilings govern the engine that still executes untrusted JavaScript on the same silo; deleting them would remove live controls from a reachable path in exchange for tidiness on a path that has no consumer yet. What was done is the part that is true: the Jint-specific ceilings have no counterpart in
WasmSandboxOptions, so the WebAssembly path is not carrying an interpreter’s concepts. If the Jint engine is ever retired, its ceilings retire with it and not before. -
Extism. Its .NET SDK is real and it wraps wasmtime. Rejected because its plugin model reaches the host through imported functions, which is precisely the edge clause 3 removes; adopting it would mean adopting an import surface in order to get an ergonomic wrapper over a runtime this module can call directly. It also interposes its own ABI between the payload and the host, and
tech-stack-currency.md§5 question 1 refuses a second definition of something already defined. -
WASI (preview 1 or 2). Rejected for the same reason in a larger size: the smallest useful WASI world is a filesystem, a clock and an environment, which is a host surface to audit rather than an absence of one. A sandbox whose first invariant is “no file system, no network” should not adopt a standard whose purpose is to provide both.
-
Stay on Jint alone. A real alternative with a real cost, and the cost is the uncatchable stack overflow: today the only defence is a hard cap of 50 frames, which also caps what a legitimate cartridge may compute. Rejected on the measurement, not on fashion.
-
wasmtime-dotnetversus the Rustwasmtimecrate at 47. Not an alternative — they are different artefacts.core/Descent.Geometrydev-depends on the crate to run ADR-056’s parity corpus in Rust’s own harness; this is a .NET binding on its own major track. There is no pin coupling them and none should be created, which is recorded incore/Descent.Sandbox/Directory.Packages.propsbecause the shape resembles theflatcpin that is coupled.
Technology currency. Wasmtime 44.0.0 read from NuGet and exercised directly on 2026-08-10 —
restore, native load, fuel, epochs, store limits, traps, serialize/deserialize round-trip and
Module.Validate. Verified against the shipped assembly’s XML documentation rather than against
project documentation, which is how the SetLimits(long?, uint?, …) signature and the obsolescence
of the whole-memory GetSpan() overload were found.
Consequences
core/Descent.Sandboxgains its first native dependency.sandbox-ci.ymlbuilds onubuntu-latest; the NuGet package carries per-RID natives, so no toolchain step is added, but the module is no longer portable purely by virtue of being managed code.DedicatedStackThreadPoolmoves out ofSandboxEngineas aninternaltype so both engines share one. The code is unchanged. A copy in the WebAssembly namespace was the alternative and was rejected: two thread pools with one behaviour drift, and they would have drifted in a security-relevant direction the first time either changed its stack sizing.- The suite grows from 576 to 645 assertions, and four existing theories failed the moment the
two new error kinds were added —
ErrorScenarios’ exhaustiveness guard refusing a kind with no real failing execution behind it. Both kinds now have one, so the disclosure and localisation scans cover both engines rather than silently covering one. That guard doing its job unprompted is the clearest evidence in this ruling thatP3’s ordering is worth its cost. - The engine’s own metrics are namespaced separately (
wasm.*rather thanast.*) so that a dashboard cannot average a pooled interpreter’s duration against a fresh store’s. They share theMetername, so a host subscribes once.
Rights-holders (ADR-079)
None. No data is retained, no processor is added, and no party who handles anything changes. A payload’s bytes are supplied per execution and nothing persists them.
Enforcement
Per ADR-045, each normative sentence names where it is enforced:
-
“
ApplicationandDomainmay type against neither port” —tests/core/Descent.ArchitectureTests/SandboxPortBoundaryTests.cs, written by this ruling because it did not exist.This bullet is a correction of itself, and the sequence is the point. It first read “the same assertion that already covers
ISandboxEngine”. There was no such assertion.upgrade-and-supersession.md§4 andDescent.Sandbox’s ownARCHITECTURE.md§2 have both stated the rule since they were written, and nothing anywhere enforced either sentence — the seam registry’s Babylon row records the same shape (“asserted a control that was never built”) and this ruling nearly committed it while adding a port.The rule was not being broken. Neither
Descent.Vtt.ApplicationnorDescent.Vtt.Domainhas a project reference toDescent.Sandbox, so the types are unnameable rather than merely unused — which isP3’s ordering at full strength and stronger than any test. What was missing was a guard on that state, and a missing project reference is one line away from being present.The first version of the guard did not work either, and that is worth more than the guard. It asserted over
Assembly.GetReferencedAssemblies(). The C# compiler omits from metadata any reference whose types are never used, so aProjectReferenceadded but not yet consumed is invisible there — verified by adding the forbidden reference and watching all 44 tests pass. The reference being added before anything uses it is exactly the case worth catching, because that is the reviewable moment. The assertion now reads the project files, and adding the reference fails it with the intended message. -
The rule’s other half — “no wasmtime type appears on the port” — is readable in
IWasmSandboxEngineand has no automated check. Description backed by review, stated out loud as ADR-045 clause 2 requires. -
“A payload may declare no imports” —
WasmSandboxEngine.DescribeShapeFailure, asserted byWasmIsolationTests(two tests, one of which asserts the refusal precedes instantiation). -
“Ten ceilings have no default” —
WasmSandboxOptions.Validate, called at registration and again in the engine’s constructor. Asserted by a theory over all ten names. -
“
MaxWasmStackBytescannot reachnew Engine” — the property setter. Asserted without constructing an engine, for the reason clause 6 gives. -
“Fuel and the wall clock are separate budgets” — two mirrored tests: fuel low and clock generous must report
FuelExhausted; clock short and fuel ample must reportTimeout. The second is what proves the epoch pump exists and runs, because epoch interruption without a pump enforces nothing at all. -
Every one of the above was mutation-tested rather than trusted. See “Verification” below.
ADR-138 — WebTransport is declined, and ADR-029’s Phase 5 evaluation closes negative
Status: Accepted Date: 2026-08-10
Context
ADR-029 selected SignalR over WebSockets as the Phase 3 transport and scoped WebTransport as a Phase 5 evaluation, blocked on three named things. The proposal was to “upgrade the ASP.NET Core SignalR implementation to enable and prioritize WebTransport (HTTP/3) over QUIC”, keeping the WebSocket fallback for environments where UDP 443 is blocked.
P7 requires that a blocking claim be checked rather than inherited — “blocked on project code” was
recorded against two trivially implementable lints for three months and was simply wrong. So all
three were checked on 2026-08-10.
What the check found
-
“SignalR has no WebTransport transport” — still true. dotnet/aspnetcore#39583 is the tracking issue, opened January 2022. It is open, milestoned
Backlog, unassigned, with no linked pull requests or branches. SignalR’s negotiated transports remain WebSockets, Server-Sent Events and Long Polling. There is no transport to prioritise:Microsoft.AspNetCore.Http.WebTransportis a separate experimental API over a raw HTTP/3 session, and building on it means giving up hubs, groups, the backplane and the client’s reconnect machinery and reimplementing them. -
“End-to-end HTTP/3 through the ACA ingress is unverified” — now settled, and settled negative. Azure Container Apps ingress supports HTTP and TCP. There is no HTTP/3, no QUIC and no UDP ingress; microsoft/azure-container-apps#270 has requested it since June 2022 and it is not implemented.
This inverts the proposal’s own framing, which is the finding worth keeping. The request was to prioritise WebTransport and keep a fallback for environments where UDP 443 is blocked. On the deployment target UDP is always blocked — not by a corporate firewall but by the platform. So the fallback would not be the exception; it would be the only path that ever runs in production, and the WebTransport path would be code that exists, is configured, appears in review as a control, and executes nowhere. This corpus has a name for that shape: the Babylon row in the seam registry “asserted a control that was never built”.
-
“Its datagram channel duplicates WebRTC DataChannels” — unchanged. No new evidence either way; ADR-029’s reasoning stands as written.
Decision
-
WebTransport is not adopted. ADR-029’s Phase 5 evaluation closes, negative, with the evidence above rather than remaining an open “not yet”.
-
No client code is written.
WebTransportTransport implements Transportis the migration ADR-029 designed for and it remains one file — but writing it now would produce a transport with no server on the other end, and this repository has recorded that exact shape three times in one file: a declared type is not a wire; a reachable endpoint is not a working feature; a field nothing writes is a field that does not exist (apps/vtt-frontend-client/src/network/README.md). A fourth instance is not needed. The seam was audited instead, and it holds:Transportnames no SignalR concept, its two request-shaped verbs are named for outcomes, and rule 2 ofcheck-network-boundary.mjskeeps@microsoft/signalrimportable from exactly one file. Nothing needs fixing to keep the upgrade cheap later. -
Kestrel HTTP/3 is not enabled, and the configuration that would enable it is documented with the reason it is not applied — see below. Enabling it would add an
Alt-Svcadvertisement for a protocol the ingress cannot carry. -
The re-evaluation trigger is recorded in
tech-stack-currency.md, and it has two clauses, both of which must hold: dotnet/aspnetcore#39583 leavesBacklogwith a shipped transport, and Azure Container Apps gains UDP or HTTP/3 ingress. Either alone changes nothing.
The Kestrel configuration, recorded because the proposal asked for it
For a silo that could serve HTTP/3, the change is small and is stated here so that the next reader does not have to rediscover it — and so that its uselessness on the current target is visible in the same place:
builder.WebHost.ConfigureKestrel(kestrel => kestrel.ConfigureEndpointDefaults(listen => listen.Protocols = HttpProtocols.Http1AndHttp2AndHttp3));Four things have to be true besides that line, and they are the reason this is not a one-liner:
- msquic must be present. ASP.NET Core’s HTTP/3 is
Microsoft.AspNetCore.Server.Kestrel.Transport.Quicover msquic, so the container image needslibmsquic; themcr.microsoft.com/dotnet/aspnetimages do not carry it. - TLS 1.3 and a certificate on the listener. QUIC has no cleartext mode, so an HTTP/3 endpoint cannot be terminated by the platform and re-offered as plaintext to the container — which is what an ACA ingress does today.
- UDP 443 must reach the container. It cannot: see finding 2.
Alt-Svcis how a browser discovers HTTP/3, and Kestrel emits it per endpoint. Advertising a protocol the path cannot carry costs a client one failed connection attempt per session before it falls back.
So the honest answer to “document the Kestrel HTTP/3 configuration changes required for the Azure Container Apps environment” is that there are none that work, and the blocker is the platform’s ingress rather than anything in this repository. Recorded as that rather than as a configuration sample that would appear to be adoptable.
Alternatives Considered and Why Rejected
- Adopt WebTransport now and accept it is unreachable, so the code is ready. Rejected: an unreachable transport is untested against a real server, and it would be maintained against a moving experimental API for as long as it takes the two triggers to fire.
- Build a raw WebTransport endpoint with
Microsoft.AspNetCore.Http.WebTransportand bypass SignalR. Technically coherent and the largest-blast-radius option: hubs, groups, the backplane, ADR-054’s reconnect ladder and ADR-050’s request-reply lease path would all be reimplemented on an experimental API, to reach an ingress that cannot carry it. Rejected on cost against a benefit of zero today. - Enable HTTP/2 for the SignalR WebSocket transport (RFC 8441). This is reachable — Kestrel
and the SignalR JavaScript client have supported WebSockets over HTTP/2 since .NET 7, and ACA
ingress carries HTTP/2. It was not adopted here and not rejected either, because Kestrel’s
HTTPS default is already
Http1AndHttp2and browsers negotiate Extended CONNECT themselves: so it may already be in effect, and claiming a change without measuring which protocol the hub actually negotiates would be manufacturing a result (P7). Recorded as an open item with a measurement attached rather than as a decision. - Leave ADR-029’s evaluation open. Rejected by §5’s rule that a declined upgrade is recorded with a trigger — “re-evaluate on the 2.0 stable release” is actionable and “not yet” is not. Two of three blockers are now settled facts, and leaving them uncited means the next reader re-does the same two searches.
Consequences
- Nothing in the running system changes. No code, no configuration, no dependency. That is the outcome, and it is stated plainly rather than dressed as a deferral.
- ADR-029’s Phase 5 clause acquires an answer, so the three-blocker sentence in
src/network/transport/transport.tsandsrc/network/README.mdis now a closed evaluation rather than an open one. Both are annotated; neither is rewritten, because both are accurate about what they describe. tech-stack-currency.md’s@microsoft/signalrrow carries the two-clause trigger.
Rights-holders (ADR-079)
None. No change to any data path.
Enforcement
- “Nothing SignalR-shaped enters the
Transportseam” — rule 2 oftools/check-network-boundary.mjs, which was already the enforcement point and was verified to be intact as part of this evaluation. - The decline itself has no enforcement point and needs none: it is the absence of a change. The
control against it being silently reversed is the trigger row in
tech-stack-currency.md.
ADR-139 — Bun is declined for both frontend pipelines, and the BFF’s runtime is not decided here
Status: Accepted Date: 2026-08-10
Context
The proposal was to migrate marketplace-frontend and vtt-frontend-client from “Node/npm” to
Bun, run the SolidStart BFF on Bun “to drastically reduce cold start times”, and move CI and
Dockerfiles to oven/bun.
Bun is three separable things here and they were evaluated separately, because collapsing them is how a decision about a runtime gets made by an argument about an installer.
(a) Bun as the installer — declined
ADR-110 moved the client to pnpm for one property: a phantom dependency is a missing-module error rather than a review comment. That property belongs to the installer’s layout, not to the lockfile.
Bun can preserve it. bun install --linker=isolated builds a pnpm-style symlinked store and Bun’s
documentation says in as many words that it prevents phantom dependencies. But it is not the
default for these two apps. Bun’s default is isolated for new workspaces and hoisted for new
single-package projects — and both frontends are single-package roots, each with its own lockfile,
because ADR-M-001 keeps the contexts physically isolated.
So adopting Bun means the property survives only while every install passes the right linker, and
that cannot be checked the way the current guard checks the tool.
tools/check-package-manager.mjs reads npm_config_user_agent, which every package manager sets on
every lifecycle script — a fact about the process, available before node_modules is written. There
is no equivalent for the linker: a bunfig.toml setting is overridden by --linker=hoisted on the
command line, and a preinstall hook cannot see the flag.
That converts a structural guarantee into an unverifiable one, which is
upgrade-and-supersession.md §7 verbatim: do not
weaken a check to accommodate an upgrade. The disposition §7 names is either an ADR arguing the
property should change — with the loss written down — or a different tool. The property is worth
more than the installer: ADR-110 found a real phantom dependency (@eslint/js, imported by the file
that configures the linter that gates every frontend build) on its first run.
And the benefit is bounded by a number ADR-110 already measured. Install is 5 s of a ~215 s
frontend-ci job — about 2% — while cargo install wasm-pack is 119 s of it. A free
installer would take 2% off this pipeline. ADR-110 refused pnpm’s own speed as a driver on exactly
this evidence; it would be inconsistent to accept Bun’s.
(b) Bun as bundler and test runner — declined, and partly impossible
- Replacing Vite is not available. SolidStart 2.0 is a Vite plugin:
solidStart()invite.config.ts, with noapp.config.ts, because 2.0 deleted the Vinxi layer. Replacing Vite with Bun’s bundler means replacing SolidStart. The seam registry’s SolidStart row records the BFF’s exit cost as “moderate and asymmetric” precisely around this. - Replacing Vitest is available and not worth it. Vitest reads the
testblock invite.config.ts, so there is one configuration rather than two that can disagree — a recorded reason intech-stack-currency.md’s test-runner row.bun testwould be a second configuration and a second bundling pipeline for the assertions, against a suite that runs in seconds. - Vite 8 with Rolldown is the current recommended production choice, stable since May 2026.
CLAUDE.md§6 rule 7 — latest ≠ newest — is the whole answer.
(c) Bun as the BFF’s runtime — not decided here, and the stated reason for it does not hold
This is the half with a real thesis, and it is refuted by two figures the corpus already owns.
-
The BFF does not scale to zero. Marketplace §2.6’s deployment table: BFF (SolidStart), Node 22 container, replicas 1 → 10, “Can it scale to zero? No — first-visit SSR cold start is a conversion and SEO cost.” A component pinned at one minimum replica has no scale-from-zero cold start in production. What remains is scale-out latency when replica 2 joins under load, which is a different and much smaller quantity.
-
Even where scale-from-zero exists, process start is noise inside the platform wall.
Q-M-013is measured (not⊙) at 8 969 ms byBENCH-M-01, and Marketplace §3.1’s findingM-F-02decomposes it: control-plane scheduling → node placement → image pull → container runtime start → process start → warmup. Process start is 100–200 ms of nearly nine seconds. §3.1 makes that argument to refuse Native AOT as a cold-start remedy, and it transfers to Node → Bun unchanged, because it is the same stage of the same pipeline.
So “drastically reduce cold start times” names a cost this architecture has already removed by pinning min-replicas, and would attack a stage worth ~2% of the wall if it had not.
What is not decided here, and why it is left with its owner. tech-stack-currency.md §3c
already carries an open finding on this exact row: CI runs Node 24, §9.1 specifies a Node 22
container, so the job does not prove the app runs on the deployed runtime, and the recorded
trigger is “aligning the container to 24, or the CI to 22 — a deployment decision”. Bun would be
a third answer to a two-way question that already has an owner and no measurement. Answering it from
inside a tooling ADR, on a cold-start argument that does not hold, is how a runtime gets chosen by
accident. The row is annotated with Bun as a considered-and-unmeasured third option; the decision
stays where it was.
Decision
- Both frontends continue to install with pnpm and with nothing else. ADR-110 stands
unamended, and
tools/check-package-manager.mjsis unchanged. No line is added to any baseline and no check is relaxed. - Vite 8 / Rolldown and Vitest 4 stay. No workflow moves to
oven/bun. - No Dockerfile changes, because there are none to change — see this file’s opening note.
- The BFF’s runtime is not decided here. §3c’s open finding keeps its owner and gains Bun as a recorded third option with the measurement it would need.
- Three re-evaluation triggers are recorded rather than “not yet”:
- Installer: Bun making
isolatedthe default for single-package projects, or exposing the effective linker to a lifecycle script so apreinstallguard can assert the property rather than the brand. Either makes (a) a live question again. - Bundler: SolidStart supporting a non-Vite build, which is a SolidStart major.
- Runtime: a measured BFF scale-out latency showing process start to be a material share of it — which requires the deployment to exist first.
- Installer: Bun making
Alternatives Considered and Why Rejected
- Adopt Bun as installer with
--linker=isolatedin every script and workflow. The version that keeps the property. Rejected because “every invocation passes the flag” is a property that depends on everyone remembering a flag — which ADR-110 already rejected in exactly these words when it considerednpm --install-strategy=nested: “the review comment again, wearing a different hat”. - Adopt Bun only for
marketplace-frontend, whose gates are lighter. Rejected: two frontends with two installers doubles the tooling surface for the smaller of the two apps, and the phantom-dependency argument does not weaken with app size. - Adopt Bun as the BFF runtime and measure afterwards. Rejected by
P7andP5: the figure that would justify it does not exist, andQ-M-013is evidence against. ADR-110’s own precedent is decisive — it recorded the honest measurement instead of the expected one and refused its own second driver. - Stay on pnpm and do nothing at all. This is what was chosen, and it has a cost worth naming: Bun’s install is substantially faster, and a future contributor will propose this again. The triggers above are what make that proposal cheap to answer.
Technology currency. Bun’s isolated-installs documentation and its default-linker rules read on
2026-08-10; bun is not installed on the machine this was evaluated on, so nothing here is a
local measurement of Bun and none is claimed. The figures that decide it (Q-M-013, ADR-110’s
install share, §2.6’s replica floor) are this repository’s own and were re-read rather than
recalled.
Consequences
- Nothing changes in either pipeline. Stated plainly.
tech-stack-currency.mdgains the package-manager row’s Bun trigger and §3c’s Bun annotation.- The seam registry’s pnpm row gains the fact that the exit cost is not symmetric across successors: leaving for Bun-hoisted re-hides phantom dependencies exactly as npm does, while leaving for Bun-isolated does not — so the row’s “low but not zero” needs the linker named.
Rights-holders (ADR-079)
None. Build-time tooling.
Enforcement
- “Installs with pnpm and nothing else” —
tools/check-package-manager.mjs, ADR-110’s enforcement point, unchanged and re-verified as part of this evaluation. - The declines have no enforcement point, being the absence of change. The control is the
trigger rows in
tech-stack-currency.md.
Verification
Mutation testing, because the WebAssembly suite passed on its first run and a green suite is not
evidence. Sixteen single-behaviour mutations were applied to the new engine, each built and run
against Descent.Sandbox.Tests.Wasm in isolation, then reverted. Results are recorded in
docs/audits/R34_Wasm_Mutation_Sweep.md, including the two
that were not initially caught and the test defect that produced them.
The full suite: 645 assertions, 0 failures, run by launching the test binary directly —
dotnet test reports zero tests executed for this project, which
core/Descent.Sandbox/ARCHITECTURE.md §9 explains.
The docs lints: adr_link_lint.py reports the row count on every run and it is the only
authoritative figure for how many ADRs exist. It read 134 rows, 0 findings before this ruling.