Resource Ceilings Are Supplied, Never Defaulted — ADR-100
Resource Ceilings Are Supplied, Never Defaulted — ADR-100
Date: 2026-08-06 · Written in English per the 2026-08-01 convention change.
Provenance: three queue items that turned out to be one.
Descent.Sandboxpublishing defaults for its DoS thresholds (item 10), a wall-clock flake class ~49 call sites wide (item 13), and the silo’s unwired sandbox composition were tracked separately and share a single cause. This file rules on the cause.This file continues the
R-numbered sequence for filing purposes only.
ADR-100 — The Sandbox Refuses To Start Until The Host States Its Resource Ceilings
Status: Accepted · Date: 2026-08-06 · interacts with 001 (AST lockouts), 046
(script effect budgets) · governs Q-017
Context
§3.1 requires that all DoS thresholds — “AST depth/width, CPU and heap budgets, token-bucket
rates” — be runtime configuration supplied by the host, never constants published in the
open repository, so an attacker reading the source cannot calibrate against the exact limit.
Descent.Geometry was built to that rule and carries no default for Q-012.
Descent.Sandbox published a default for all eleven of its bounds, and its own documentation
stated the opposing position outright: “the secure configuration is the one you get by doing
nothing.” Both positions are respectable. That is why this needed a ruling rather than a fix.
What settled it was not the argument but the damage, which had already happened twice:
- The published default was the value the architecture rejects.
MaxExecutionTimedefaulted to 50 ms. The simulation tick is 50 ms (Q-001), and §4.3 spends a paragraph explaining that a per-execution limit equal to the tick lets one script consume a room’s entire budget. Any host callingAddDescentSandbox()adopted precisely the rejected value, and nothing at the call site looked wrong. - It silently governed the library’s own test suite. 63 provider constructions across 41 files inherited it; 94 assertions depend on a script completing inside it. Two tests — whose subjects were engine construction count and memory measurement, neither about timing — failed under parallel load. A default nobody chose is a limit nobody reviewed, and the review that did not happen was not an operator’s, it was the library authors’ own.
The silo, meanwhile, holds a ProjectReference to the sandbox and never registers it, and
Descent.Vtt.Application declares no script port — so §4.3’s regime is unbuilt. That makes
this the cheapest moment this decision will ever have: the breaking change has no host-side
blast radius today, and §3.1 says as much in its own words about the geometry crate — the rule
“is cheap to honour in a crate’s first commit and expensive to retrofit once a published API
has shipped a constant.”
Decision
- Seven ceilings lose their defaults and the sandbox refuses to start without them:
MaxExecutionTime,MaxMemoryAllocationBytes,MaxQueueSize,MaxAstCacheSize,MaxAstCacheEntries,MaxScriptLength,MaxEffectBufferSize. These are the ceilings an attacker calibrates against. - Four keep their defaults, and the split is argued rather than drawn by convenience.
MaxRecursionDepthis a safety ceiling, not a tunable refusal threshold — exceeding it is an uncatchable native crash, its setter hard-caps it, and the number buys an attacker nothing they could not learn by recursing until something breaks.MaxStackSizeBytesis sized so that counter trips first.EnginePoolSizeis capacity. AndAllowUnsafeCartridgeTypesmust keep its default, because the entire point of it is that the safe value is the one you get by doing nothing. - The configuration delegate is required.
AddDescentSandbox(services)no longer compiles.SandboxOptions.Validate()runs at registration and names every missing ceiling at once. - Secure-by-default is preserved, not abandoned — this is the reconciliation. A library that refuses to run until the operator states its resource ceilings is secure by default in the strongest available sense: it cannot run under limits nobody chose. The two principles only looked opposed because “default” was read as “a number” rather than as “a behaviour”, and the safest behaviour here is refusal.
- The silo’s composition stays unwired, deliberately. Registering a pool with no consumer
would build 20 Jint engines at startup for nothing. When it is wired it goes through a
first-party overload supplying
ScriptBudget.Q017with no way to omit it — the patternAddDescentRoomRandomnessalready uses (“there is no overload that invents a secret”). This ADR is what makes that wiring safe rather than what performs it.
Alternatives Considered and Why Rejected
- Leave the defaults; require the host to override. Rejected: it is the status quo, and the status quo produced both failures above. A requirement satisfied by remembering is not a requirement — it is the shape §14.6 and ADR-064 already reject elsewhere.
- Grant §3.1 a documented exception for libraries that must be usable standalone. This was the serious alternative and it is not unreasonable — a library with no defaults is harder to adopt. Rejected because the exception would have to be written as “published limits are acceptable when the consumer is expected to override them”, and the consumer that failed to override them here was the library’s own test suite, 63 times. An exception whose own author cannot honour it is not an exception, it is a repeal.
- Delete all eleven defaults. Rejected as over-broad, and specifically wrong for
AllowUnsafeCartridgeTypes, where removing the default would mean a host could construct an options object whose unsafe setting was unstated. Uniformity would have inverted the safety property on the one option where the default is the control. - Make the ceilings
requiredmembers. Rejected on mechanics: the options infrastructure activatesSandboxOptionsreflectively, sorequiredyields a runtime failure at an unhelpful place instead of a compile error, and buys nothing over validation. - Nullable ceilings instead of a zero sentinel. Rejected: it expresses “unset” more precisely and pushes a null-check into every reader on the hot path, to make one registration-time check marginally cleaner.
Consequences (including negative)
- This is a breaking change to a published MIT package. Every existing consumer must supply seven values or fail at startup. Mitigated only by timing: there are no consumers outside this repository today.
- The library is less pick-up-and-go, and that cost is real rather than notional. A reader evaluating it now has to make seven decisions before seeing it run.
- Direct construction bypasses validation.
new SandboxOptions()still compiles and yields all-zero ceilings. It fails closed — the engine refuses immediately — but as a bare timeout rather than a message naming what was missing. Two sites in this repository construct directly; both now go through a helper. A consumer that does this gets a worse diagnostic than the registration path, and this is stated rather than left to be discovered. - The suite needed one shared statement of ceilings (
SandboxTestLimits), or 63 call sites would each have grown a copy of seven numbers — reproducing inside the tests the scattered constants this ADR removed from the library. - Item 13 is resolved by this ADR’s restructuring, though not on the first attempt, and the
false step is recorded because it is the instructive part. Routing all 63 sites through one
constant made the repair a one-line change. Raising it to 30 s appeared to cost a 4× suite
slowdown — 3m07s against 50 s — and was therefore initially declined as an unpriced trade.
Per-test timings then attributed 149.75 s of a 139.8 s increase to a single test.
Timeout_DoesNotDiscardThePooledEnginespinswhile(true){}five times deliberately, so its runtime is five times the budget. Given its own ceiling, the suite runs in 49 s at a 30 s budget, and the wall-clock sensitivity is gone from all ~49 inheriting sites.- How it was missed the first time is worth more than the fix. The scan for “tests
asserting a timeout without their own budget” was run at file granularity for a property
that is per-test, and the file already contained a
MaxExecutionTimebelonging to a different test — so the real culprit was excluded and the scan confidently reported two candidates, neither of which was it. An aggregate computed at the wrong granularity does not return a wrong number; it returns a confident one, which is why “the 4× is unexplained” was the right thing to write down rather than a guess to act on.
- How it was missed the first time is worth more than the fix. The scan for “tests
asserting a timeout without their own budget” was run at file granularity for a property
that is per-test, and the file already contained a
Rights-holders (ADR-079)
None. No new class of data is retained. This decision governs configuration shape only.
Enforcement
- The compiler.
AddDescentSandboxwithout a delegate does not compile — the strongest available boundary (P3), and the reason the API changed rather than only the documentation. SandboxOptions.Validate()at registration, naming every unsupplied ceiling in one message so a host fixes them in one pass rather than seven.- Zero is the unset sentinel and is unambiguous: none of the seven is meaningful at zero, so a partially-configured host fails rather than half-running.
ScriptBudget.Q017(Descent.Vtt.Application) is the value the silo must pass when the composition lands, and it validates itself againstQ-001’s tick.- Not enforced: nothing prevents a consumer from
new SandboxOptions()and reading the properties directly. Recorded as a gap rather than claimed as covered.
§11 Index Line
ADR-100: Descent.Sandbox loses the published defaults for the seven resource ceilings an attacker calibrates against and refuses to start until the host supplies them, with the configuration delegate made mandatory so omission is a compile error and Validate() naming every gap at registration; four options keep defaults on argued grounds, AllowUnsafeCartridgeTypes necessarily so because there the default is the control; secure-by-default is preserved rather than traded, because a library that refuses to run under limits nobody chose is secure by default in the strongest sense; a §3.1 exception for standalone-usable libraries was rejected because the consumer that failed to override the defaults was the library's own suite, 63 times. (Governs Q-017; interacts with ADR-001, ADR-046.)