Date: 2026-08-01
Mandated by: ADR-045 (Documentation Governance), clause 3
Status: This is the single source for every quantity in the system. Numbers in the whitepaper and in the revision documents cite a Q-ID rather than restating a value.
This table supersedes the scattered versions in W1_Tick_Budget_Revisions.md (Q-001…019) and W6_Governance_And_Closure.md (Q-020…035). Those tables are retained as the derivation record for their own rulings, but where the two disagree, this file wins.
Why this table was created three months after R2 closed, and what that fact means
ADR-045 requires in as many words that every quantity be consolidated into a single registry. What actually happened: W1 established Q-001…019, W6 appended Q-020…035, and the correction to Q-015 / Q-016 was written in a third document (Open_Items_S1_and_BENCH04.md).
Three documents, each internally correct, and the “single source” property never held.
This is exactly the shape of F-37 — whose cause was recorded as “numbers are consolidated but restating them in prose is permitted”, while what actually happened is more basic: the numbers were never consolidated at all; each ruling simply opened a new table inside its own file. The concrete consequence, found while building this table, is that W6’s closing table still claimed BENCH-04 decided Q-016 — when Q-016 had long since been settled by analytical recomputation and BENCH-04 demoted to a verification (corrected 2026-08-01).
A registry that is mandated to exist does not exist until a file actually bears that name.
Status markers
Marker
Meaning
Invariant
An architectural constant. Changing it requires an ADR, and overturns every entry derived from it
Normative
Set by a ruling. Changing it requires amending that ADR
Derived
Computed from other entries. The formula must be recorded — without one it is a normative value wearing the costume of a derived one
⊙ Estimated
Derived from an assumed unit cost and not yet measured. Must not be marked normative until the corresponding benchmark completes
Existing
Carried over from the source document; not re-examined by this round of rulings
Pending
Identified as needing a bound, with the value not yet decided. The existence of this row is itself the deliverable — it separates “undecided” from “unbounded”
Part 1 — Tick and simulation budgets (Q-001…019)
ID
Name
Value
Source
Status
Q-001
TICK_PERIOD_MS
50 (20Hz)
§5.2 architectural constant
Invariant
Q-002
MAILBOX_OCCUPANCY_TARGET_MS
28 per 50ms window
W1 Table A
⊙ Estimated
Q-003
TICK_MSG_P99_MS
8 (Segment A ≤3, Segment B ≤5)
W1 Table A; measured 2026-08-13 (BENCH-01, BENCH-03) — see “The measurement rig’s first results”
Normative
Q-004
REQUEST_GRAIN_METHOD_P99_MS
5
§4.4 (the tick is explicitly exempt, ADR-033)
Existing
Q-005
INTENT_AGGREGATE_MS
15 per window
W1 Table A ← Q-008
⊙ Estimated
Q-006
HOUSEKEEPING_AGGREGATE_MS
5 per window
W1 Table A
⊙ Estimated
Q-007
EFFECT_APPLY_MAX_PER_TICK
1,000
ADR-046 ← Q-003; measured 2026-08-13 (BENCH-01): 0.67 µs per Effect against an assumed ⊙3 µs, so 1,000 costs 0.670 ms of Segment A’s 3 ms
Existing (host runtime configuration per §3.1; must not ship as an open-source constant)
Q-018
INTERP_BUFFER_MS
30 (fibre) – 150 (unstable)
§8.3
Existing
Q-019
CHUNK_FLUSH_PER_ROOM_SEC
50
W1 Table B, ADR-035
⊙ Estimated
The Q-015 / Q-016 correction, retained because it is the most important lesson in this table. The old definition treated GEOMETRY_POOL_MS_PER_ROOM_SEC = 300 as a capacity estimate and derived ROOM_WEIGHT_UNIT ≈ 0.85 cores per full-budget room from it. A budget ceiling is not a capacity unit — a ceiling is an isolation mechanism describing a room that will not occur. Using it as a capacity unit over-provisions hardware by roughly an order of magnitude and makes §10.2’s cost model unusable. See Open_Items_S1_and_BENCH04.md.
Q-016’s composition is refuted by measurement (2026-08-13, ADR-177), and its total is not
re-derived. The recomputation that produced ⊙0.02 / ⊙0.08 cores stated that “Segment B
accounts for ⊙95% of the expected cost in a 50-player room”. BENCH-03 measures Segment B at
0.675 ms per tick = 13.5 ms per room-second = 0.0135 cores at 50 viewers — which is 17%
of the ⊙0.08 total, not 95%. Meanwhile the fog token bucket admits 6 advances per
room-second at Q-075’s 44 ms, so the geometry term is bounded at 264 ms per room-second =
0.264 cores, nineteen times Segment B at its bound.
So the driver is geometry and the composition was inverted. That reverses §5.2’s sentence
“The dominant cost is viewers, not world complexity … per-viewer snapshot assembly outweighs
geometry by more than an order of magnitude” — measured, geometry outweighs per-viewer
assembly by roughly the same margin in the other direction. Half of that reversal was
already visible on 2026-08-02, when the fog-advance measurement refuted Q-015b’s ⊙3 ms and
nobody re-read the sentence that rested on it; this round supplies the other half and the
sentence is corrected through ADR-177 rather than in passing.
The TOTAL is deliberately not re-derived, and the reason is the one Q-015b already
carries. The geometry term is an admitted allowance, not an expected cost: a room where
nobody moves advances nothing, and a room at the bucket’s ceiling pays 264 ms. Turning that
into an expected value needs a measured fog-budget utilisation from real play, which does not
exist. Publishing a number derived from the bucket’s ceiling would repeat the exact error the
paragraph above records — a budget ceiling is not a capacity unit — one correction later.
Q-016 therefore keeps its ⊙ and loses its explanation.
Part 2 — Data lifecycle, security and frontend (Q-020…035)
ID
Name
Value
Source
Status
Q-020
EVENT_PARTITION_COUNT
64 (hash(room_id))
ADR-036
Normative (coupled to Q-021)
Q-021
PROJECTION_SHARD_COUNT
Derived: = Q-020 (deliberately aligned, so one shard’s rooms fall in the same partition)
ADR-043
Derived
Q-022
WORKER_MIN_REPLICAS
2
ADR-043
Normative
Q-023
NEON_KEEPALIVE_INTERVAL
Derived: < autosuspend window / 2
ADR-049
Derived
Q-024
EDGE_FETCH_MAX_BYTES / _REDIRECTS / _RATE
Pending / 2 / per-account token bucket
ADR-039
Normative (the byte ceiling is pending)
Q-025
DECODED_PIXEL_MAX
Pending
ADR-039 (a decompression bomb has a small file size, so a byte ceiling does not catch it)
Normative (pending)
Q-026
PLUGIN_FRAME_AGGREGATE_FRACTION
Pending (a fraction of the frame budget)
ADR-040
⊙ Estimated (awaiting S6)
Q-027
PLUGIN_INTEREST_SET_MAX
Pending
ADR-040
Normative (pending)
Q-028
LEASE_EXPIRY_TICKS / RENEW_INTERVAL_MS
40 ticks (= 2s, expressed in tick sequence rather than wall clock) / 500
ADR-050
Normative
Q-029
PEER_GRACE_BUFFER_MS
150
ADR-050 clause 3
Normative
Q-030
DICE_ANIMATED_MAX
20
ADR-041
Normative
Q-031
DICE_TRAJECTORY_VARIANTS_K
Pending
ADR-041
Normative (pending)
Q-032
FONT_SUBSET_GLYPHS
~3,000 (common zh-TW glyph set)
W5 / D-2
⊙ Estimated
Q-033
VRAM_RESIDENT_BUDGET_BYTES
Pending (per profile)
F-38 correction, §9.6.2
⊙ Estimated (currently absent entirely — OPFS streaming does not reduce VRAM pressure, so no value here means the protection does not exist)
Normative (pending, blocked on the product’s entitlement-tier model, not on measurement — see note below)
Q-035
DIAGNOSTIC_SAMPLE_RATE
Pending (raised when a room is flagged)
ADR-057
Normative (pending)
Part 3 — Entries registered when this table was created (Q-036…050)
These numbers were always in the whitepaper but never carried a Q-ID. They fall into two classes: those with a definite value (Q-036…046), and those the document already claims are bounded while the value was never decided (Q-047…050). The second class matters more — listing them is the only way to separate “undecided” from “unbounded”.
With a value
ID
Name
Value
Source
Status
Q-036
WORLD_EXTENT_M
8,000 × 8,000
§5.3
Invariant (decides whether Q-043’s coordinate space is sufficient)
Q-037
COORD_FORMAT
i32.16 fixed point; intermediates widened to i64/i128
§5.3 Determinism Contract, ADR-017
Invariant (changing it overturns bit-for-bit equality across hosts)
Q-038
BRANCH_BUDGET_PER_ROOM
16 (live branches, including the GC policy for unreferenced branches)
§7.3, ADR-020
Normative
Q-039
EPHEMERAL_PUBLISH_RATE_HZ
60 (≤ 6 peers) / 20 (≤ 16) / 10 (more)
§9.6.3, ADR-012
Normative (60Hz is a ceiling, not a constant — SFU fan-out is O(N) per sender and O(N²) per room)
Q-040
OPFS_LRU_DAYS
30
§9.6.2
Normative
Q-041
ASSET_CHUNK_BYTES
512 KB
Derived: sized to §9.6.1’s frame budget rather than to convenience
Derived
Q-042
SHUTDOWN_GRACE_S
30
§4.4
Normative (must cover all three steps: flush → commit confirmation → snapshot write, ADR-037)
Q-043
MICRO_BATCH_WINDOW_MS
~50
§7.1
Existing (numerically equal to Q-001 and semantically unrelated — this is the window of events that may be lost under SIGKILL, not the tick)
Q-044
TOUCH_TARGET_PT
44
§9.7.2
Existing (platform HIG; drives the offset-proxy drag design, ADR-066)
Q-045
C_COMPANION_VIEWPORT_IN
⊙8 (effective viewport diagonal; below this is C-Companion)
§9.7.1, ADR-066
⊙ Estimated (awaiting measurement on real devices — the criterion is whether the tactical map and the character sheet can be shown at once, not the screen size itself)
Q-046
CARTRIDGE_DEPRECATION_RELEASES
2 (v2.9 deprecate → v3.0 remove)
§7.5.4, ADR-071
Normative
Claimed to be bounded, value pending
ID
Name
Source
Why it must have a value
Q-047
HW_DECODE_SESSIONS_MAX
§9.4
§9.6.3’s dice-animation cap (Q-030) cites in as many words “the same concurrency-cap pattern as §9.4’s hardware decode sessions” — and the value cited as the precedent has no value itself
Q-048
ARCHIVE_OVERLAP_WINDOW
§7.4, ADR-036
Cold-tier migration “retains an overlap window before reclaiming locally”. The window length decides whether a failed verification is recoverable — too short and the source data is already gone when the checksum disagrees
Listed explicitly as an EDoS face (“one account scripting tree traversal across fifty rooms”). Q-013 bounds the per-room rate and does not catch cross-room amplification
Q-050
OFFLINE_QUEUE_MAX / _TTL
§9.5 Guardrail 4, ADR-011 / 051
“The offline queue is bounded in count and age; on overflow the client refuses further optimistic actions.” Without values that rule is unenforceable — and it exists to prevent accumulating a divergence too large to reconcile meaningfully
Added by the R3 rulings (Q-051…053)
ID
Name
Source
Status
Q-051
ROOM_DORMANT_AFTER_MS
ADR-072 (the grace period a room stays Active after the last client backgrounds)
Normative (pending)
Q-052
KEEPALIVE_AFTER_DORMANT_MS
ADR-072 (how long keep-alive continues after entering Dormant)
Normative (pending)
Q-053
CHECKOUT_CANDIDATE_TTL_MS
ADR-075 (how long candidate state is retained)
Normative (pending)
Q-034 and Q-049 are blocked on a commercial decision, not on measurement (ADR-087). A convention organiser, a play-by-post organiser with fifty rooms, and an account systematically scraping campaigns produce identical telemetry. Set the bound where the organiser needs it and the scraper is unbounded; set it where the scraper stops and the organiser breaks mid-event. A quota that bounds legitimate high volume and abuse with one number encodes a compromise in a constant nobody will later recognise as one, so these two stay pending until the product’s entitlement-tier model exists. Cost containment does not wait: the existing per-account bounds continue to protect the bill. Where a bound must exist before the tier model does, it is recorded here as a cost bound and never as an abuse threshold — a bound doing one job must not be documented as doing two.
R4 rulings (Q-054…055) — added 2026-08-01, in English per the convention change
ID
Name
Source
Status
Q-054
ERASURE_COMPLETION_DEADLINE
ADR-078 clause 5 (time from an accepted erasure request to the last derived artefact being cleared)
Normative (pending)
Q-055
IDENTITY_BACKUP_RETENTION_MAX
ADR-078 clause 5 (retention ceiling for backups of the keystore and the linkage store)
Q-056 was unset, and 32 KB was specifically rejected as a starting value. It bounds blast radius, not routing — size and routing are independent, and a 5 KB snapshot published through Clients.Group(...) fans out to every silo exactly as a large one would, so this is not the control that enforces §8.3. Its unblocking measurement was the per-viewer Full Snapshot size distribution, because a cap set below it would break the delivery path ADR-090 exists to protect.
That measurement now exists, and it returns the same number that was rejected.SnapshotSizeMeasurementTests, 2026-08-02: 2,000 actors over 400×400 units, 500
viewers, AOI radius 60 units.
measure
entities
bytes
headroom at Q-056
p50
131
5,308
6.2×
p99
180
7,268
4.5×
max
184
7,428
4.4×
§5.2’s reference interest set
200
8,068
4.1×
Re-measured 2026-08-09, when DisclosedEntity gained elevation and facing. The
previous row set — 4,260 / 5,828 / 5,956 / 6,468, at 7.7× / 5.6× / 5.5× / 5.1× — was
taken against a 32-byte struct and is superseded rather than wrong: same room, same
seed, same 500 viewers, eight more bytes per entity. Q-056 itself did not move.
The cap bounds blast radius and nothing in this measurement argues about that; what
moved is the derivation underneath it, which is the part a reader builds on.
The rejection was still right, and this is the point of it.32 KB was
refused as an unmeasured guess; it arrives now as a measured one, and the two are
different facts that happen to share a value. A cap adopted on the earlier
reasoning would have been correct by luck, and nothing would have distinguished it
from the version of that guess that was 4× too small.
Derivation. One DisclosedEntity is 40 bytes on the wire — Uuid (two
ulongs, 16 B) plus four int32s (raw_x, raw_y, raw_z, rotation) plus a
bool, padded to the struct’s 8-byte alignment — and FlatBuffers stores structs
inline, so a snapshot is 68 + 40n bytes for n entities. Q-056 therefore admits
817 entities, or 4.1× §5.2’s reference interest set.
The struct was 32 bytes and 68 + 32n until 2026-08-09, when elevation and
facing were added to the transform. The eight bytes were accepted into the tick
payload rather than decoupled onto their own cadence, and that is a decision worth
recording because ADR-092 makes the opposite call for attributes a few lines below.
The test is cadence, not size: a facing changes exactly when a position changes —
they are one transform, produced by one gesture — whereas a stat block changes when
a stat changes and a fog chunk on its own ladder. Splitting a token’s orientation
onto a second message would mean two packets per move, each arriving on a different
tick, and a client interpolating a position toward one answer while rotating toward
another. The 128× term ADR-092 excluded is not present here: this is 1.25×, it is
bounded by the same n, and it does not grow with cartridge content.
This bound holds only because cartridge attributes are out of band. Ruled
2026-08-02: the tick-driven snapshot carries transform state only, and a
cartridge’s MessagePack payload travels as a separate, event-driven message sent
when a stat changes or when a client opens a sheet. Measured against the
alternative, the reference room’s snapshot is 827,268 bytes — every entity
carrying Q-060’s 4,096-byte ceiling — which at 20Hz is 16.5 MB/s per viewer and
ends ADR-014’s bandwidth budget outright. §8.1 previously implied the payload rode
in the packet; it does not. Q-056 and that ruling are one decision: raising
the cap without re-examining what the packet carries would restore the 128×
term it exists to exclude.
Enforcement point:Descent.Vtt.Application.Rooms.PerViewerSnapshot, whose
wire form is schemas/snapshot.fbs, with the distribution pinned by
SnapshotSizeMeasurementTests.
Still derived rather than measured, and the difference is recorded: the entity
count is measured, but the bytes are computed from the schema’s layout because
flatc is not yet wired into the build and no serializer exists to weigh. The
figure becomes a measurement when it does, and it is not expected to move — the
layout is specified, not estimated.
Q-055 must be ≤ Q-054, and this is the only coupling in this table that is a correctness constraint rather than a tuning one. A backup of the linkage store that outlives the erasure deadline can be restored, and restoring it resurrects exactly the record erasure destroyed. Set Q-055 above Q-054 and ADR-078’s entire mechanism becomes decorative while continuing to report success — the erasure job completes, the metric goes green, and the data comes back on the next restore drill. This is the least architectural clause in ADR-078 and the easiest to violate without noticing.
Q-051 and Q-052 must not be tuned independently. It is their sum that a user actually experiences as “how long you can step away before being penalised”: too short a Q-051 and everyone glancing at their phone at once interrupts the tick, too long and backgrounded rooms idle; too short a Q-052 and Neon resumes repeatedly, too long and ADR-049’s cost saving does not hold. They are registered as two entries because they act on different mechanisms, not because they can be decided separately.
Geometry pathfinding bounds (Q-058…059) — added 2026-08-02, in English per the convention change
Q-057 is intentionally unassigned; these two IDs were allocated by ruling.
These are the remaining two arguments of descent_geometry_evaluate (Q-012 is the
third). §3.1 requires all three to be host-supplied runtime configuration and
never fields a caller can set — a four-byte budget field in the request was a
real defect, and the wire_codec fuzz target turned it into a request for four
billion node expansions from a packet-sized input.
ID
Name
Value
Source
Status
Q-058
PATH_EXPANSION_BUDGET
4,096
Measured; see derivation below
Normative
Q-059
PATH_REGION_CELLS_MAX
65,536
Derived: one chunk = Q-011 256 × 256
Derived
Q-058’s derivation, measured 2026-08-02 (GeometryCostMeasurementTests,
release-profile crate at submodule 8b6a23c). The worst case for A* is an
unreachable goal, because the search must expand everything it is allowed to
before it can say so; a successful search stops early and says nothing about what
an attacker can provoke. Over a full Q-059 region:
path_budget
Worst case
Share of a tick (Q-001)
Outcome
4,096
6.1 ms
12%
PATH_BUDGET_EXHAUSTED
16,384
25.0 ms
50%
PATH_BUDGET_EXHAUSTED
65,536
49.4 ms
99%
region exhausted before the budget bound
The two limits bound different things and neither is redundant.Q-059 is
checked before any allocation and bounds the extent a request may name;
Q-058 bounds the work actually done inside it. Leaving Q-058 at or above
Q-059 makes it inert — the region is exhausted first — and a single query then
costs a whole tick. 4,096 is chosen so the pathological case costs ~2% of
Q-015a, which is what lets a rate limit on path requests be a bound on cost
rather than a hope.
The gameplay cost is real and is the reason this may need revisiting. A
maze-like region can exhaust 4,096 expansions on a route that genuinely exists,
returning PATH_BUDGET_EXHAUSTED. That outcome is sound — it claims nothing
about whether a path exists — but it is a refusal a player can feel, so this
value trades responsiveness in complex terrain for a hard cost bound.
Fog cadence ladder and the Q-012 reduction — added 2026-08-02
Q-015a (300ms per room-second) was breached by a single observer at the old
Q-012 of 512. This section records the measurement, the reduction, and the
mechanism that now enforces the ceiling at runtime.
Measured cost of one fog advance over a 256 × 256 chunk (Q-010/Q-011),
release crate at submodule 8b6a23c:
Occluders
0
32
128
192
256
384
512
ms
0.26
9.31
20.42
25.19
29.37
36.15
41.26
Derived cost model:cost ≈ 0.26 + 1.81·√n ms, fitting every point above to
within 2%. The square root is the BVH traversal, not a curve fitted for
convenience — which is why this may be used to interpolate an untested occluder
count and the raw table may not.
Q-012 is reduced from 512 to 256. At 512 an advance costs 41.26ms, so one
observer advancing every tick is 825ms per room-second — 2.8× Q-015a. At 256 an
advance costs 29.37ms, which the ladder below can hold. Q-015a was deliberately
not raised: it is an isolation ceiling describing a room that should not exist,
and raising a ceiling to fit the load it was built to catch would leave the
mechanism intact and the protection gone.
The room’s advance allowance is derived, not chosen:
Q-015a 300ms ÷ 29.37ms per advance = 10.2 → 10 advances per room-second,
for the entire room with every observer combined.
At Q-001’s 20 ticks per second that is one advance every other tick. A single
observer at full tick cadence costs 587ms and breaches the ceiling alone, which
is why the ladder is not a tuning preference.
The ladder (§5.3’s mask cadence axis):
Tier
Interval
Advances/sec
ms/room-second
Focus — the acting party
200ms (4 ticks)
5.0
146.9
Near — in the interest set
500ms (10 ticks)
2.0
58.7
Far — present, off-screen
1s (20 ticks)
1.0
29.4
Dormant — not moved recently
2s (40 ticks)
0.5
14.7
Checked against this table’s own reference room. It is 50 viewers but five
mask sets — “four parties plus the Keeper” — not fifty. One focus + one near + three far =
10.0 advances/sec = 294ms, inside the ceiling. Fifty independent masks were
never affordable at any tier, and the reference case only closes because masks are
per party rather than per viewer.
The ladder is policy; the token bucket is the enforcement point. Four
focus-tier parties each obey their 200ms interval and together cost 587ms. A
per-room bucket admitting 10 advances per room-second is checked at dispatch, and
an advance that cannot be afforded is skipped — the mask stays as it was and
MaskStaleness grows, which §5.2 already designates as the correct degradation.
Without it Q-015a is a row in a table that nothing checks.
Enforcement point:Descent.Vtt.Application.Rooms.FogBudget, called from
RoomGrain.AdvanceTickAsync before every dispatch.
Only the cadence axis of §5.3’s ladder is implementable in the host. The
crate’s C ABI advances a fixed 256 × 256 chunk — PACKED_CHUNK_BYTES is a crate
constant and there is no half-resolution query — so the granularity axis requires
a change to Descent.Geometry, not to the backend. Recorded here because the two
axes read as interchangeable in §5.3 and are not.
What the reduction costs.Q-012 is a authoring-facing hard limit, so halving
it halves the dynamic occluders a chunk may hold. That is a gameplay-visible
constraint on level authoring, not merely an internal bound, and it is the price
of keeping Q-015a honest.
The ladder’s four intervals (Q-068…071) — registered 2026-08-02
The ladder above was ruled on 2026-08-02 but its four intervals were left in a
table without ids, so they existed as prose here and as literals in
FogCadence.cs and in no third place that either could be checked against. §9.2
then restated the cadence as “20Hz” for a further session because nothing bound the
two together. These ids are assigned to values that were already ruled; no value
changes here.
ID
Name
Value
Source
Status
Q-068
FOG_CADENCE_FOCUS_TICKS
6
Re-derived 2026-08-04 against Q-075 (was 4)
Normative
Q-069
FOG_CADENCE_NEAR_TICKS
15
Re-derived 2026-08-04 against Q-075 (was 10)
Normative
Q-070
FOG_CADENCE_FAR_TICKS
30
Re-derived 2026-08-04 against Q-075 (was 20)
Normative
Q-071
FOG_CADENCE_DORMANT_TICKS
60
Re-derived 2026-08-04 against Q-075 (was 40)
Normative
Registered in ticks, not milliseconds, because ticks are what the host stores
and milliseconds are derived. The interval is ticks × Q-001 — Focus is
4 × 50ms = 200ms — so a change to Q-001 moves all four without any of them
being edited. Registering the millisecond form as primary would have created four
values that silently disagree with the tick period the moment Q-001 moved, which
is the shape of defect Q-016’s original definition had.
The four are not independently tunable, because Q-015a is checked against
their mix rather than against any one of them. A tier advances
1000 ÷ (ticks × Q-001) times per second, so its cost is that rate × 29.37ms —
Focus is 1000 ÷ 200 = 5.0/s × 29.37 = 146.9ms. The reference room — one Focus, one Near, three
Far — spends 146.9 + 58.7 + 3 × 29.4 = 294ms of Q-015a’s 300ms. The margin
is 6ms. Halving Q-068 alone adds 146.9ms and breaches the ceiling on its own;
so does promoting a second party to Focus. Any edit to one of these four requires
recomputing the reference mix, and the token bucket is what makes the breach a
skipped advance rather than a missed tick.
These bound presentation, and the presentation layer is where their cost is
paid.Q-068 is the value §9.2’s fog-edge animation exists to hide: at 90Hz per
eye the Focus interval plus ADR-033’s one-tick collection lag is about
twenty-two frames of staleness. Raising Q-068 to buy room-second budget is
therefore never free — it is paid in XR fog smoothness, and the two decisions have
to be made together.
Enforcement point:Descent.Vtt.Application.Rooms.FogCadenceOptions, whose
defaults are pinned against this table by
FogCadenceOptionsTests.TheTierIntervalsAreTheValuesTheRegistryRecords.
Intentional leakage and permeability (Q-072…075) — added 2026-08-02, all pending
ADR-091 rules that audibility and leaked illumination are server-computed and
dispatched pre-attenuated. Four quantities follow from it. All four are
Normative (pending): the ruling exists, the values do not, and registering the
rows now is what distinguishes “undecided” from “unbounded” — the point of this
table’s Pending marker.
ID
Name
Source
Status
Q-072
ACOUSTIC_ATTENUATION_LEVELS
ADR-091 clause 3 (ordinal levels an audio attenuation may take)
Normative (pending)
Q-073
OPTIC_LEAKAGE_LEVELS
ADR-091 clause 3 (ordinal levels a leaked light intensity may take)
Normative (pending)
Q-074
LEAKAGE_EVENT_RATE_MAX
ADR-091 clause 4 (leakage events per concealed entity per room-second)
Normative (pending)
Q-075
PERMEABLE_ADVANCE_COST_MS
44
ADR-091 clause 7; measured 2026-08-04, see below
Q-076
OPPOSED_CONTEST_RATE_MAX
6
ADR-094 clause 6; derived 2026-08-04, see below
Ecosystem bounds (Q-078…Q-081) — added 2026-08-05
Introduced by the Marketplace, Studio and ecosystem-module integration. All four are
pending, and per §3.1’s rule the corresponding protection does not exist until each has a
value — stated here rather than left to be discovered at the enforcement point.
Pending (measured against Q-077’s 64 on the qualified form)
ADR-098 enforcement; Studio_Architecture.md §4.4
Normative (pending)
Q-080
STUDIO_SANDBOX_GRAIN_ROOM_WEIGHT
Pending
Studio_Architecture.md §7.2; input to ADR-002’s weighted placement director
⊙ Estimated (awaiting measurement)
Q-081
FINALIZE_MOVE_MIN_INTERVAL_MS
Pending
Ecosystem_Module_Designs.md §3.3; input to §5.2’s per-player intent cap
⊙ Estimated (awaiting measurement)
Q-083
STUDIO_MODULE_CONTRACT_WINDOW_RELEASES
Pending
ADR-109 enforcement; Studio_Architecture.md §1.5.2. How many releases a Studio module contract stays loadable — the reasoning of Q-046’s cartridge window: a window stated in releases can be planned against, one stated as “current” breaks people
Normative (pending, blocked on an update-cadence decision, not on measurement)
Q-078 is an alarm threshold, not a limit. The fresh-read escape hatch is already
bounded to one primary attempt per lookup by ADR-097 clause 5, which is a structural
bound and is not pending. Q-078 is the rate above which the replica is declared
unhealthy. Until it has a value the metric is observed and not alarmed, which is a
monitoring gap rather than an open resource path — the distinction matters because the
two are usually conflated and only one of them is urgent.
Q-079 is not a duplicate of Q-077.Q-077 bounds what a cartridge may declare;
Q-079 bounds what a creator-namespaced key expands to after Studio prefixes it with a
package id (@blood-studios/vampire-pack:boss). The authored suffix can satisfy Q-077
while the qualified form does not, and the qualified form is what is stored and
transmitted. Measuring the wrong one is the failure this entry exists to prevent.
Q-080 and Q-081 are both inputs to budgets that already exist, not new budgets.
Q-080 feeds the ADR-002 placement director, which is currently admitting an unmeasured
load if authoring sessions are co-placed with live rooms; Q-081 feeds §5.2’s occupancy
table, whose per-player intent caps are derived rather than chosen. Adding a per-second
finalize from every seated player without deriving it again is the exact move that table
exists to prevent.
Pending rather than ⊙, and the reason is that the right value is a measurement nobody
has taken. The TTL must be the shortest window that reliably completes a fetch of the
largest asset a room can hold, on the slowest profile that must load it — a Profile C tablet
on mobile data is the binding case, not a desktop. Publishing a guess would set a security
parameter by convenience, and a TTL that is too short is a support ticket while one that is
too long is a bearer capability living longer than its purpose.
It bounds the URL, not the asset. Once fetched, the bytes are in OPFS and this quantity
has nothing further to say about them — the access control is at issuance (ADR-123), and
§9.6.2’s LRU scavenger governs what happens after.
The Marketplace’s Q-M-034 is the same idea in the other bounded context and is
deliberately not shared. Two systems mint URLs against different storage under different
authority; one quantity spanning both would couple a VTT room’s liveness rule to a
storefront’s checkout flow.
⊙ Estimated — benchmark: the custom-asset size distribution below
Q-086
ACCOUNT_STORED_BYTES_PLUS
Pending (5 GB proposed)
ADR-121 (Conditional)
Pending — blocked on a recurring-billing surface, not on measurement
Q-087
ACCOUNT_STORED_BYTES_PRO
Pending (20 GB proposed)
ADR-121 (Conditional)
Pending — as above
Q-088
EXEMPTION_DISTINCT_OWNERS_MIN
Pending
ADR-120
Pending — the second half of the shared-copy rule
Q-085 is ⊙ and the benchmark it names is real rather than a formality. 200 MB was
chosen as a round number and not measured against anything. The measurement that would
make it normative is the size distribution of custom uploads by class — an 8K 2D map and
a single high-poly .stl are each plausibly the whole allowance, which would make the free
tier “one asset” rather than “a small library”. That may be the intended product, but it must
be a decision taken against real numbers rather than a consequence discovered by users.
It bounds STORED bytes in R2, not uploaded bytes. The two diverge under re-upload and
versioning, and the stored copy is the one that is billed. Recorded because “quota” reads as
either and the check in §6.2 predates any definition.
It is a cost bound, never an abuse bound (ADR-105). It scales with entitlement tier, which
is exactly what ADR-105 permits for a cost bound and forbids for an abuse one. Nothing here
defends against a hostile account, and no sentence elsewhere may cite it as though it did.
Q-086 and Q-087 are Pending rather than ⊙, and the distinction matters. Numbers are
proposed (5 GB, 20 GB) but ADR-121 is Conditional: ADR-M-027 defers subscriptions from
Marketplace v1, and the Marketplace is merchant of record, so these cannot be charged for
today. They are blocked on a commercial mechanism, not on measurement — the same category
ADR-087 uses for Q-034’s daily allowance, and for the same reason: publishing a value would
imply an entitlement tier that does not exist.
Q-088 bounds the loophole, not the cost. ADR-120 exempts an asset from Q-085 only where
the purchaser is not the publisher; Q-088 is the second guard, because two colluding accounts
publishing for each other satisfy “purchaser ≠ publisher” trivially. It is Pending because
the right value depends on catalogue behaviour nobody has observed — a threshold set too high
denies the exemption to a genuinely niche pack with few buyers, which is the honest failure
mode of setting it by guess.
§5.2’s rule that an authoring-driven ceiling may refuse outright; chat.fbs
Normative
Q-091
CHAT_SENDER_NAME_MAX_CHARS
64
chat.fbs; the server authors this field, so the bound is on the server’s own output
Normative
Q-092
CHAT_MESSAGE_MAX_BYTES
16,384
Derived: see the formula below
Derived
Normative rather than ⊙, because neither estimates anything. A ⊙ figure is a
placeholder for a benchmark; these two are choices about what a room will refuse, and there
is no measurement that would settle them. Q-090 is the same category as Q-012
(DYNAMIC_OCCLUDER_MAX): an authoring-driven ceiling, which §5.2 says may refuse rather than
degrade, because a person who types 2,001 characters can act on being told so.
Enforcement point (ADR-045):ChatCodec.TryDecode refuses a body over Q-090 before
allocating the string, and RoomGrain.SubmitChatAsync refuses it again. Two checks over one
rule, for the reason RoomGrain.SetOccludersAsync records: the decoder guards the transport
and the grain guards the room, and there is already a second caller planned for the grain
(a system message authored by the ruleset) that does not arrive through a hub.
Q-092’s formula, stated because rule 3 requires it.
Q-092 = next power of two ≥ (Q-090 × 4) + (Q-091 × 4) + 256
= next power of two ≥ 8,000 + 256 + 256
= next power of two ≥ 8,512
= 16,384
The ×4 is UTF-8’s worst case per UTF-16 code unit, which is what both hosts count a
“character” in — and it deliberately over-counts by 2×, because a 4-byte UTF-8 sequence
occupies two code units rather than one. A ceiling is the one place where being
conservative is free. The 256 is the FlatBuffers framing: two inline Uuid structs (32 B),
the version, the kind, the timestamp, and the vtable and offsets.
Q-092 is exactly half of Q-056, and that is a decision rather than an arithmetic
coincidence.Q-056 bounds any SignalR message at 32,768 as defence in depth; setting
chat’s own ceiling at half of it means a chat frame can never be the message that meets the
transport cap, so a Q-056 breach on this silo always names a different path. A ceiling that
could touch the shared one would make the shared one useless as a diagnostic.
The Q-084 gap is pre-existing and its reason is not recoverable. Noticed while
allocating Q-090: the registry runs Q-083 → Q-085 with no row, no ruling and no note,
unlike Q-057, which line 192 records as intentionally unassigned. It is written down here
rather than quietly closed by renumbering, because a gap somebody deliberately left and a gap
somebody skipped by accident look identical afterwards, and only the record can tell them
apart. This note is the record; the reason itself is marked unrecoverable rather than
invented.
ADR-130; Marketplace §4.5. The silo’s bound on one redemption call
Normative
Q-094
TICKET_ACQUISITION_TIMEOUT_MS
3,000
ADR-130; Marketplace §4.5. The client’s bound on one acquisition call
Normative
Normative rather than ⊙, and the distinction matters here more than usual. A ⊙ figure
is a placeholder for a benchmark that will replace it. Neither of these estimates a cost:
they are choices about how long a connection attempt may spend waiting on another bounded
context before giving up and retrying, and no measurement of the Marketplace settles that —
a faster Marketplace does not make a longer wait correct. BENCH-M-03 measures the redemption
round trip’s cost on the Marketplace side and is a different question in a different corpus.
Both are bounded above by a quantity this corpus does not own. The ticket’s own lifetime
is ~15 seconds, and it is the Marketplace’s figure (§4.5), stated there rather than mirrored
here — §8’s cross-context rule is explicit that Q-M-* is a separate namespace and that
restating another context’s number is how the two come to disagree. What follows from it is
the shape of these two: every millisecond either side spends waiting is a millisecond that
cannot be spent acquiring a fresh ticket and retrying, and a retry is the only recovery a
single-use credential has. A timeout approaching the credential’s life is therefore not
“patient”, it is a guaranteed second failure.
Q-093 < Q-094 deliberately, and the asymmetry is not an oversight. Acquisition happens
once per attempt on a client that has nothing else to do; redemption happens on the silo’s
connection path, where a held request is server capacity. The client can afford to wait
longer than the server can.
Enforcement point (ADR-045):HttpTicketRedeemer.RedeemAsync applies Q-093 on a linked
cancellation token — not HttpClient.Timeout, which surfaces as the same exception a caller’s
cancellation does, and the two must stay distinguishable or a deployment drain reports as a
Marketplace outage. MarketplaceIdentityOptionsValidator refuses a non-positive value at
startup. Q-094 is applied by an AbortController in auth-client.ts.
TicketRedemptionTests.ARedemptionThatOutrunsQ093IsUnavailable asserts the first against a
gated handler rather than a longer sleep, per §14.8.
Both ship a default, unlike Q-082, and the reason is which direction the absence fails.
An unset intent rate is unbounded work for an attacker. An unset timeout falls back to
HttpClient’s own 100 seconds — bounded badly and visibly, not unbounded. A host that wants a
different value sets one; a host that sets none gets a working silo rather than one that
refuses to start over a number it has no opinion about.
Both bound a string this context did not author, and neither is an estimate. They are the
same category as Q-090 and Q-091 — a choice about what this side will refuse — rather than
a placeholder for a measurement. What makes them worth registering is that both values reach a
database key or an indexed comparison, and an unbounded externally-authored string arriving
over a network is the shape every one of this corpus’s input rules exists to refuse.
Q-095 is retrospective. It has been in the code since ADR-130 as a bare MaxLength
constant, uncited — recorded here rather than quietly left, because P5 is explicit that a
number in code without a Q-ID is invisible to both the reader and the lint, and the fact
that it predates this ruling does not make it less so.
Refusal, not truncation, in both cases. A truncated subject is a different principal
that looks valid, and two subjects sharing a prefix would collapse into one linkage row — a
disclosure defect wearing the costume of a formatting bug.
Enforcement point (ADR-045):MarketplaceSubject.TryCreate and ResourceUrn.TryCreate
are the only constructors of either type, and both refuse rather than clamp;
TicketRedemptionTests.AnOverlongSubjectIsRefused asserts Q-095 at the redemption boundary.
What it is. How long a cartridge’s AssemblyLoadContext stays loaded after the last room
releases it. Ten minutes.
Why the value is not zero, which is what §4.4 reads as. Orleans deactivates idle rooms as
a matter of routine — idle collection, rebalance, silo migration — so unloading the instant a
reference count reaches zero makes a silo re-read, re-verify and re-JIT the same cartridge
continuously under ordinary load. It is also worse than it first appears:
AssemblyLoadContext.Unload is asynchronous, so a context is unloaded when requested and
collected some time later, and rapid churn leaves several generations of one cartridge alive
simultaneously. That is the duplicate-context waste ADR-134 rejected per-room contexts to
avoid, arriving by a different route.
This is a chosen policy value, and it is Normative rather than ⊙ because it is not
derived from anything. No unit cost was assumed and no benchmark is pending, so marking it
⊙ would imply a measurement is owed that is not. It is ten minutes because that is
comfortably longer than an Orleans idle-collection cycle and short enough that a cartridge
withdrawn from a silo stops occupying memory within one operator’s attention span. The
evidence that would overturn it is a measured AlcCartridgeLoader reload rate on a silo
under real room churn: a non-zero steady-state reload rate means the window is too short, and
a resident set that never shrinks on a silo whose catalogue has changed means it is too long.
Neither number exists yet, which is why this paragraph names them rather than a benchmark ID.
The sweep period IS this window, deliberately, so there is one number rather than two —
CartridgeSweeper runs its PeriodicTimer at Q-097. The consequence is that a cartridge is
reclaimed somewhere between one and two windows after its last room let go. Nothing depends on
the exact moment, and a second configurable interval would be a second thing to get wrong for
no behaviour anyone can observe.
Enforcement point (ADR-045):AlcCartridgeLoaderTests.ACartridgeStaysLoadedForTheWholeLingerWindow
asserts both directions against a stepped TimeProvider — resident before the window elapses,
swept after — and ARoomReturningInsideTheWindowReusesTheLoadedCartridge asserts the
behaviour the window exists to produce. CartridgeLoaderOptionsValidator refuses a negative
value at startup; zero is legal and means “unload as soon as nothing holds it”, which is what
the collectibility test uses.
Volumetric visibility work budget (Q-102) — added 2026-08-11, ADR-155
ID
Name
Value
Source
Status
Q-102
VOLUME_VISIT_BUDGET
(no value)
ADR-155; VolumeBvh::segment_visibility takes it as a required argument
Pending
The existence of this row is the deliverable, and its value deliberately is not — the
distinction rule 5 draws between undecided and unbounded. What ADR-155 decided is the
unit; what nothing yet decides is the number.
The unit is node visits, and refusing milliseconds is the decision. The proposal that
produced this quantity asked for a 300 ms budget on volumetric visibility, with a fallback
to 2D projection when exceeded. That cannot be an input to this crate. Descent.Geometry
exists to give bit-identical answers on two hosts (ADR-017), and a wall-clock reading is not a
function of the inputs: two hosts running the same query on the same scene would cross a
millisecond threshold at different moments and return different answers — 3D on one,
projected 2D on the other. A timer is the single input that destroys the property the crate is
for.
Q-058 is the precedent, not an analogy.PATH_EXPANSION_BUDGET bounds A* by expansions
rather than by milliseconds for exactly this reason, and it was measured rather than guessed.
This is its sibling in a different subsystem.
The host still gets its 300 ms, in the place a deadline belongs: outside the deterministic
core, where ADR-033’s dispatch-now / collect-next-tick surface already owns wall-clock. A
budget the tick may abandon and a budget the answer depends on are different things, and
collapsing them is how a deterministic core stops being one.
What would make it Normative. The worst case is bounded and known — a BVH over Q-012’s
256 panels has at most 2n - 1 = 511 nodes, so a budget at or above 511 can never be exceeded
and would make the mechanism inert (the same trap the registry records for Q-058 against
Q-059). The useful value is the typical traversal cost on a real map, which needs the
geometry cost measurement BENCH-04 covers, extended to volumetric scenes. That benchmark does
not exist for 3D and is named here rather than assumed, per §4’s rule that a provisional number
with no named benchmark stays provisional forever.
Until it has a value, the protection is the API rather than the number.VolumeBvh::segment_visibility takes a VisitBudget as a required argument with no
Default, so a host that has not chosen one fails to compile — the same discipline
Bvh::build applies to Q-012’s ceiling, and for the same reason: a silent default is how an
unbounded traversal reaches production.
Enforcement point (ADR-045):VisitBudget has no Default and no constructor that
supplies one, which is the structural half. volume.rs’s
the_budget_outcome_is_a_function_of_the_inputs asserts the determinism the unit was chosen
for, and an_ample_budget_answers_and_a_zero_budget_does_not pins both ends of the ladder so
“exhausted” is neither unreachable nor universal.
Derived:Q-056 + 28, the measured MessagePack envelope constant for the longest hub method taking a byte[]
Derived
Q-056 and this are different quantities, and they had the same value.Q-056 bounds the
payload — 32,768 bytes of FlatBuffers. MaximumReceiveMessageSize bounds the encoded
message, payload plus envelope. SignalR’s default for the second is also 32,768, so the two
read as one control and neither the code nor the corpus distinguished them.
Measured 2026-08-11 (dotnet run tools/bench-hub-envelope.cs), a payload at exactly
Q-056 inbound as SubmitOccluders(byte[]):
protocol
encoded
overhead
against SignalR’s 32,768 default
JSON
43,747
10,979
over
MessagePack
32,796
28
over
Both. A Q-056-sized payload has never in fact been admissible on this hub: the transport
refused it before the hub saw it, and the effective inbound payload ceiling was roughly
24.5 KB under JSON — a number nobody chose and nothing recorded. That is the defect
Q-101 closes.
The derivation uses MessagePack’s constant and not JSON’s, and that is a decision. JSON
stays registered so an unmigrated client can still negotiate (ADR-154), but sizing every
connection’s receive buffer for base64 would inflate it by a third in order to admit a maximal
payload from a legacy client — a case that has never worked and is not being made to work.
A legacy client at the cap is refused loudly at the transport; a migrated client is not.
The 28 is a worst case rather than a convenient one.bench-hub-envelope.cs enumerates
every hub method taking a byte[] and measures against the longest name, so adding a longer
method later moves the constant and the benchmark says so.
Enforcement point (ADR-045):SignalRRegistration.MaxReceiveMessageBytes is the only
place the value appears and AddDescentSignalR is the only caller;
HubProtocolEnvelopeTests.TheInboundLimitAdmitsAQ056PayloadUnderMessagePackAndRefusesItUnderJson
asserts both arms — the admission and the deliberate refusal — so a later widening to
“fix” the JSON case cannot pass silently.
§6.1, ADR-060. Retrospective — the value has been in the whitepaper since the content-protection section was written
Existing
Q-099
CHUNK_DECRYPT_WEBCRYPTO_MS
⊙ 0.243
tools/bench-asset-decoder.mjs, 2026-08-11
⊙ Estimated
Q-100
CHUNK_DECRYPT_WASM_SIMD_MS
⊙ 4.738
tools/bench-asset-decoder.mjs, 2026-08-11
⊙ Estimated
Q-098 is Existing rather than Normative, and the distinction is the honest one. §6.1
states 512KB and says it is sized “to the frame budget in §9.6.1 rather than to convenience”,
but the derivation is not recorded anywhere and cannot be reconstructed — so marking it
Derived would require inventing a formula (P9 forbids that) and marking it Normative would
claim a ruling that does not exist. What the row buys is that descent-asset-decoder’s
CHUNK_PLAINTEXT_BYTES and the Azure Worker’s splitter now cite one identifier instead of two
copies of a literal.
Q-099 and Q-100 are ⊙ although they were measured, and the reason is the environment
rather than the arithmetic. Both figures come from a real run of a real decoder over a real
512KB chunk — nothing here is derived from an assumed unit cost. But the run was Node
v24.18.1 on one x86-64 developer machine, and the claim the audit makes is about a browser
on a player’s device. Those differ in ways that could move either number: a phone with no
AES-NI narrows the gap from one side, and a browser’s crypto.subtle crossing a process
boundary per call widens it from the other. Rule 2 applies unchanged — no normative sentence
may rest on either without saying so, and ADR-153’s decision is written to survive both being
wrong by a factor of three, because the finding it rests on is structural.
The benchmark that would make them normative is the same script run under Playwright
against the Guardrail 7 device matrix, reporting a distribution rather than a best-of-five. It
does not exist. Naming it is what stops these two becoming provisional forever
(docs-and-adr-workflow.md §4).
What the two figures are for.Q-100 / Q-099 ≈ 19.5 is the ratio ADR-153 turns on: the
WebAssembly decoder is an order of magnitude slower than crypto.subtlestructurally,
because WebAssembly has no AES instruction in any shipped or proposed extension, so
RustCrypto’s aes 0.9.2 — which has no wasm32 backend and falls to the fixslice bitsliced
software cipher — is the best any wasm build can do.
+simd128 is a real improvement and it is nowhere near enough, which is the finding.
Measured against a build of the same source with the flag removed:
build
ms/chunk (3 runs)
artefact
v128 opcodes
+simd128
4.738 / 4.738 / 4.780
42.9 KiB
714 of 17,235
no simd128
5.012 / 5.024 / 5.021
54.9 KiB
0 of 24,400
5.3% faster, and the spread inside each arm is under 1%, so the gain is real rather than
noise. LLVM did autovectorise the bitslice — the census is dominated by V128Xor (129),
V128And (30), V128Not (32) and the I64x2 shifts, which is exactly the boolean algebra
fixslice is made of. It buys 5% against a 1,950% deficit.
Q-099’s own variance is much larger than Q-100’s and the row does not hide it: three
runs gave 0.258 / 0.348 / 0.290 ms, a 35% spread, because at a quarter of a millisecond the
measurement is substantially call overhead. The best observed figure is recorded, which makes
the ratio a lower bound on crypto.subtle’s advantage rather than a flattering one.
Enforcement point (ADR-045): none, and that is correct. Neither figure bounds anything;
they are evidence for a decision, and the decision’s enforcement point is
decoder.test.ts › records the shipped preference as false, which pins
WASM_DECODER_PREFERRED.
SDK client footprint (Q-082) — added 2026-08-06
ID
Name
Value
Source
Status
Q-082
SDK_CLIENT_BUNDLE_BUDGET_KIB
Pending
ADR-099 consequences; §12.1 (@descent-vtt/sdk is delivered to a browser client)
Pending
Pending, deliberately not ⊙, and the difference is the whole reason this row exists.
A ⊙ figure is an estimate awaiting a benchmark — it has a number and a named measurement.
This has neither: no budget has been decided and nothing has been measured, so publishing
an estimate would invent the first half of a decision nobody made.
What the row records is that the SDK’s client-side footprint is currently unbounded, not
merely untuned — the same distinction §3.1 draws for Q-033 and Q-050. ADR-099 removed the
platform’s opinion about which validator a creator uses, which is correct for the contract
and means the SDK can no longer bound this quantity on the creator’s behalf. What it can
bound is its own footprint, and that is what this entry is for.
It is not a blocker for ADR-099: the decision’s safety rests on server-side re-validation
(ADR-098), not on any size. It is a blocker for claiming the SDK is lightweight, and it
exists so that claim is not made before there is a number behind it.
Attribute key bound (Q-077) — added 2026-08-04
ID
Name
Value
Source
Status
Q-077
ATTRIBUTE_KEY_MAX_BYTES
64
ADR-095 clause 3
Normative
Enforcement point:Descent.Vtt.Sdk.AttributeSchema.TryCreate, on every
declaration, and on a prefix declaration additionally that it leaves room for a member.
This is not a bound that was missing; it is a bound that was not needed until
ADR-095. Before prefix declarations, every writable key was spelled out by a
cartridge, so key length was bounded by the cartridge’s own vocabulary — an
enumerated, reviewed, finite list. A prefix family makes the suffix
caller-supplied, which turns key naming into client-controlled input to a quota. That
is the same shape §3.1 exists to prevent, arriving through a door ADR-095 deliberately
opened.
It does not replace Q-060, and the two answer different questions.Q-060
bounds the payload in aggregate at 4,096 bytes; Q-077 bounds what any single key may
take out of it. Without it, one 4,000-byte key is a legal payload that has consumed the
whole budget — technically bounded, practically a denial of the actor’s own attributes.
Why 64. It must exceed the longest key any shipped cartridge declares, and BRP’s
own cartridge-level bound is 32 bytes — so 64 is double the strictest consumer and
leaves room for a ruleset with longer conventions. At 64, one key is 1.6% of
Q-060, so a payload cannot be dominated by a single name. At Q-061’s ceiling of
128 keys the two interact predictably: 128 × 64 = 8,192, comfortably above Q-060, so
Q-060 remains the binding constraint and Q-077 is the per-key floor beneath it
rather than a second aggregate.
Q-072/Q-073 and Q-074 may not be decided independently, because the channel
capacity is their product. A concealed entity leaks at most
log₂(levels) × rate bits per second. Four levels at five events per second is
~10 bits/s — ample to locate something over a minute of listening — while four
levels at one event per second is not. Neither factor is meaningful alone, and
bounding one while leaving the other open produces a number that looks like a
control and is not. This is the same coupling Q-051/Q-052 carry, with a product
in place of a sum.
These are security bounds that will be argued as aesthetic ones. A muffled
growl stepping between Q-072 levels sounds worse than one sliding continuously,
and the request to raise it will arrive from sound design rather than from anyone
who has read ADR-091. Recorded here so the next person to weigh it knows both hats
are on the same head.
Q-075 blocks the re-derivation of Q-015a’s advance allowance and of
Q-068…Q-071. Today a ray terminates on its first blocking hit; accumulating
attenuation means continuing through every permeable occluder on the path, so the
measured 29.37 ms that 300ms ÷ 29.37ms = 10 advances rests on is not expected to
survive. Until Q-075 exists, the ladder remains derived from the impermeable
cost and permeability does not ship — shipping first would leave the token bucket
admitting advances against a cost that no longer holds, and the symptom would be
tick overruns in rooms with heavy permeable geometry, a long way from the cause.
Its unblocking measurement is the existing fog-advance benchmark re-run with
permeable occluders at Q-012’s ceiling.
Q-076 is coupled to ADR-094 clause 2’s outcome vocabulary for the same reason
Q-072/Q-073 are coupled to Q-074. An opposed contest resolved under the
default policy reports one of three outcomes — won, lost, tied — so each contest
discloses at most log₂(3) ≈ 1.58 bits about the opposition’s hidden rating, and
the recoverable total is that figure times the rate. Randomness does not hide the
rating; it is what makes each contest an independent sample of it. Widening the
vocabulary (reporting the margin, or the opposition’s degree) and raising the rate
are therefore one decision, not two.
Its unblocking measurement is behavioural rather than performance. The question
is how many contests against one opposition a legitimate scene actually contains —
a stealth approach past a sentry is a handful, not fifty — and the bound is set
above that and below the number at which a rating is recoverable to within a band.
Until Q-076 exists, ADR-094 clause 6 is described and unenforced, and the
default policy bounds the per-contest disclosure while nothing bounds the rate.
Q-076 does not apply to a contest the keeper has declassified (ADR-094
clause 7). Once a rating has been shown to the table, rate-bounding its re-display
protects nothing. The exemption is per contest and recorded in the stream; it is not
a property a room can acquire.
Q-075 measured — 2026-08-04
Value: 44 ms per permeable fog advance, at Q-012’s ceiling of 256 occluders.
Enforcement point: permeable_advance_cost.rs in the geometry crate, printing the
table below; permeable_bvh_equivalence.rs holds the property that makes the
comparison meaningful.
The measured quantity is a ratio, and the absolute is derived from it. The
benchmark box runs the impermeable advance at 12.99 ms where the 2026-08-02
measurement recorded 29.37 ms — it is roughly 2.2× faster, so its absolute numbers
cannot be written into a ladder derived on the other machine. What transfers is the
multiplier, because both arms run on the same box against the same geometry.
Occluders
Impermeable
Permeable
Ratio
16
3.09 ms
3.36 ms
1.1×
64
9.08 ms
10.47 ms
1.2×
128
9.45 ms
12.26 ms
1.3×
256
12.99 ms
18.27 ms
1.4×
Stable across runs at 1.4–1.5× at the ceiling. Q-075 = 29.37 ms × 1.5 ≈ 44 ms,
taking the upper end because the conservative direction for a cost is the larger one.
ADR-091 clause 7 expected worse, and this is the headline. The clause anticipated
that removing the early exit would break the derivation outright — a ray that must
visit every crossing rather than stopping at the first looked like an order-of-magnitude
change. It is 1.4×. The BVH prunes by the ray’s bounding box, and the overwhelming
majority of rays cross very few occluders, so “no early exit” costs almost nothing on
the rays where there was nothing to exit early from. The penalty is real and it is
a constant factor, not a change of complexity class.
The comparison is like-for-like by construction. A permeable set whose members are
all opaque reveals exactly what the impermeable advance does, asserted cell for cell.
Without that property the measured difference would be part cost and part answer.
The permeable arm is deliberately not all-opaque. One occluder in four is opaque
and the rest attenuate partially, because a wholly opaque field lets saturation at
Fx32::MAX short-circuit the accumulation — which would understate exactly the cost
being measured.
Q-015a’s advance allowance and the Q-068…Q-071 ladder — re-derived 2026-08-04
ADR-091 clause 7 requires the ladder to be re-derived from Q-075 rather than
assumed to have survived it. It did not survive unchanged.
The allowance falls from 10 to 6.
Q-015a 300 ms ÷ 44 ms per advance = 6.8 → 6 advances per room-second, against
the 10 the impermeable cost bought.
The existing ladder breaches the ceiling under permeability. The reference mix —
one Focus, one Near, three Far — runs at 10.0 advances/second, which at 44 ms is
440 ms against a 300 ms ceiling. A 140 ms overrun; the ladder as it stands does
not hold.
Two ways to fit, and they are a product decision rather than an arithmetic one.
(a) Slow the ladder. Multiply every interval by the cost ratio. This is
arithmetically exact — scaling every rate by 1/1.5 scales the mix’s total cost by
1/1.5 — and lands on the same margin the old ladder had:
ID
Was
Becomes
Cost in the reference mix
Q-068 Focus
4 ticks (200 ms)
6 ticks (300 ms)
146.7 ms
Q-069 Near
10 ticks (500 ms)
15 ticks (750 ms)
58.7 ms
Q-070 Far
20 ticks (1000 ms)
30 ticks (1500 ms)
88.0 ms (×3)
Q-071 Dormant
40 ticks (2000 ms)
60 ticks (3000 ms)
—
Total 293.4 ms of 300 ms; margin 6.6 ms, against the old ladder’s 6 ms.
The cost of (a) is presentation, and it lands on a shipped decision. The Focus
tier goes from 5 Hz to 3.33 Hz. §9.2 states the fog cadence as 5 Hz, and
epic_presentation_smooth_fog.md fixes its temporal crossfade at exactly 200 ms
with the invariant that the duration “must be strictly fixed and never adaptive to the
magnitude of the mask delta”, because a delta-dependent duration is a timing side
channel forbidden by ADR-034 clause 3. That invariant survives — the value becomes
300 ms and stays fixed — but the epic’s number changes, and the token/mask desync it
already records as an open item grows from 250 ms to 350 ms.
(b) Cut Q-012 instead. Cost is ≈ 0.26 + 1.81·√n ms impermeable, so a permeable
advance costs 1.5 × (0.26 + 1.81·√n). Setting that equal to the 29.37 ms the current
ladder is built on gives √n ≈ 10.7, so Q-012 would fall from 256 to ~112
occluders. The ladder, §9.2’s 5 Hz and the epic’s 200 ms all survive untouched; what
shrinks is how much dynamic geometry a room may contain.
Recorded rather than chosen, because the two trade different things. (a) costs
every room a slower fog edge; (b) costs authored scenes their occluder budget and
re-opens a Q-012 that was itself reduced from 512 only on 2026-08-02.
Ruled 2026-08-04: (a), and permeability ships
Q-012 stays at 256. A ~112-occluder ceiling was judged a fatal product
constraint — modern maps are complex, and the limit would be met by authoring rather
than by abuse, which is the wrong place for a DoS bound to bite. Slowing the Focus
tier from 5Hz to 3.33Hz is an acceptable trade for a turn-based game.
Q-068…Q-071 are updated in the table above and in FogCadenceOptions, and
AdvancesPerRoomSecond falls from 10 to 6. Permeability is unblocked: ADR-091
clause 7’s condition is satisfied, the measurement exists, and the ladder has been
re-derived from it rather than assumed to have survived.
The presentation consequence is owned rather than absorbed. The temporal
crossfade in epic_presentation_smooth_fog.md becomes 300ms, and the epic’s
invariant is unchanged — the duration stays fixed and never adaptive to the mask
delta, which is what ADR-034 clause 3 forbids. The token/mask desync that epic
already records as an open item grows from 250ms to 350ms. It was previously
described here as covered by the client-side prediction work in a file named
epic_frontend_prediction_proposal.md — no such document has ever existed in
this repository (found 2026-08-06 by doc_reference_lint.py, which was written
to catch exactly this). The desync is therefore not covered by anything: it is
an open item against §9.6.4’s prediction path, whose scope is set by ADR-034
clause 3, and whose enabling work is the WASM geometry build in §11’s Phase 4
list. Recorded as uncovered rather than re-pointed at a plausible neighbour —
epic_presentation_smooth_fog.md is a different epic and citing it here would
reproduce the original defect with a name that happens to resolve.
Q-076 derived — 2026-08-04
Value: 6 contests against one opposition per room-second. Enforcement point:
OpposedContestBudget.TryAdmit, a continuously-refilling token bucket keyed per
opposition, capped at one second’s allowance.
Derived behaviourally rather than measured, as the pending row anticipated. The
two ends:
Above legitimate play. A scene contests one opposition a handful of times — a
stealth approach past a sentry is two or three rolls, not fifty. Six is roughly
double the busiest honest case, so ordinary play never meets the bound.
Below recovery. Estimating a hidden win-rate to within ±5 points needs on the
order of a hundred samples. At six per second that is ~17 seconds of uninterrupted
automated contesting against one target — outside any scene, and long enough to be
seen.
Per opposition, not per room. A party contesting six sentries once each learns
almost nothing about any of them; one player contesting one sentry six times is
sampling a single distribution. A room-wide limit confuses the two and is paid for by
the first.
The coupling to ADR-094 clause 2 holds at this value. Three outcomes is
log₂(3) ≈ 1.58 bits per contest, so the channel is ~9.5 bits/second against one
opposition. Widening the outcome vocabulary raises the first factor and therefore
requires this number to be revisited — the BRP suite asserts ContestOutcome has
exactly three values, naming Q-076 in the failure message, so the two cannot drift
apart silently.
What this bound does not do, recorded because a rate limit invites the assumption
that it does. It makes the automated case infeasible; it does not stop patient
grinding at one contest every few seconds for an hour. What covers that is not
arithmetic — it is that a hundred consecutive Hide attempts against the same sentry
happen in a room with a keeper in it. The bound exists to make the fast case
impossible and the slow case visible, and it should not be described as more.
Descent.Vtt.Domain’s Actor stores rule-specific state in a schema-less payload
that only the owning cartridge understands. A schema-less payload with no bound
is an unbounded allocation reachable from a client message — the same shape as
the defect §3.1 exists to prevent, where a four-byte budget field in a geometry
request asked for four billion node expansions. These three bound it.
ID
Name
Value
Source
Status
Q-060
ACTOR_ATTRIBUTES_MAX_BYTES
4,096
Ruling 2026-08-02
Normative
Q-061
ACTOR_ATTRIBUTES_MAX_KEYS
128
Ruling 2026-08-02
Normative
Q-062
ACTOR_ATTRIBUTES_MAX_DEPTH
3
Ruling 2026-08-02
Normative
Enforcement point:Descent.Vtt.Sdk.AttributeBag.TryCreate, and again in
AttributeBag.TryApply on the result of every change. Checking only the opening
payload is how a bound gets defeated by a sequence of individually reasonable
updates, so the second check is not redundant. Refusal is a return value rather
than an exception because the input arrives from the wire, where a malformed
payload is expected rather than exceptional.
Q-060 measures content, not serialized length, and the formula is recorded
here because rule 3 requires it. Per entry: the UTF-8 byte count of the key,
plus the value’s size — 0 for null, 1 for a boolean, 8 for an integer, the
UTF-8 byte count for text, and the sum of the elements for a list or nested bag.
It deliberately does not measure the JSONB encoding, which depends on a
serializer the contract layer does not name and must not: the SDK is a public
package a cartridge compiles against separately, and pinning the bound to a
serializer version would make the same payload legal on one host and illegal on
another. The stored JSONB is larger than Q-060 by its own framing, which is
the persistence layer’s budget to hold and not this one.
Q-061 counts keys across the whole structure, not the top level. Counting
only the outer bag would let a payload hide a thousand keys one level down and
still satisfy the check that exists to stop exactly that.
These three bound a cartridge, and cartridges are trusted code. §4.4 and
§12.2.6 are explicit that an AssemblyLoadContext is a versioning and unload
boundary and not a security boundary — a loaded .NET cartridge runs
in-process with full host privileges. So these bounds are not a sandbox. They
exist because the payload’s contents originate from a client, and because a
first-party cartridge with an honest bug should degrade to a refusal rather than
to an allocation the host cannot survive.
Interaction with ADR-071 — ruled 2026-08-02: history is exempt. ADR-071 gives
attribute definitions a retired tombstone state and makes removal a two-release
deprecation (Q-046), so a long-lived campaign accumulates attributes in its
stream that no current definition covers. An event that was valid when written
remains valid forever: tombstoned and historical attributes do not count
against Q-060 or Q-061 during hydration. These two quotas guard the
active, writable surface — what a client can cause to be allocated now — and
history is not that surface, because it was validated on the way in. Without this
exemption a campaign that satisfied every bound when its events were written could
simply stop rehydrating, and the cause would be a limit it never violated.
Q-062 is not exempt, because depth is not a quota. It bounds recursion —
fingerprinting and serialisation both walk the structure — so an unbounded
historical depth is a stack overflow rather than a large allocation. No stream can
legitimately hold one: the limit has never been looser than it is now.
Enforcement point:AttributeBag.TryRehydrate and
AttributeBag.TryApplyFromHistory, used only on the replay path;
AttributeBag.TryCreate and TryApply remain the live path and keep the quotas.
Known gap, recorded rather than left to be found. The write path cannot yet
distinguish a tombstoned key from an active one, because ADR-071’s
attribute-definition registry does not exist. An actor already over the live quota
is therefore read-only today: every proposal against it is refused. That is the
right fail-safe direction — a refusal, not an unbounded allocation — but it is not
the end state, and it is pinned by
HistoricalAttributeTests.AnActorOverTheLiveQuotaIsCurrentlyReadOnly so the
behaviour changes deliberately when the registry exists.
Hydration retry and abandonment bounds (Q-063…067) — added 2026-08-02
§7.4’s hydration saga made one attempt and stopped; only a reactivation retried,
so a room whose database blinked stayed unusable until something happened to address
it again. It now retries inside the activation, and these five quantities bound how
hard and for how long.
They are host-supplied runtime configuration (RoomHydrationOptions,
Descent:Rooms:Hydration), like the §3.1 geometry limits and for the same reason:
the values below are the shipped defaults and a ruling, not a compile-time constant a
caller can observe or set.
ID
Name
Value
Source
Status
Q-063
HYDRATION_MAX_ATTEMPTS
12
Ruling 2026-08-02
Normative
Q-064
HYDRATION_RETRY_CAP_MS
30,000
Ruling 2026-08-02
Normative
Q-065
HYDRATION_RETRY_BASE_MS
500
Ruling 2026-08-02
Normative
Q-066
HYDRATION_ABANDON_AFTER_MS
120,000
Ruling 2026-08-02
Normative
Q-067
KEEPALIVE_AFTER_ABANDONED_MS
30,000
Ruling 2026-08-02
Normative
Full jitter is part of the specification, not an implementation detail. The
delay before attempt n is a uniform draw over [0, min(Q-064, Q-065·2ⁿ)) — the
whole interval, not a band around the cap. Rooms do not fail independently: a
database going down is one event that every room notices at nearly the same
instant, so an undithered backoff preserves that alignment perfectly and delivers
the entire fleet as a single spike onto a server that is trying to recover, which
re-synchronises the herd for the next round. Jitter added around a deterministic
delay narrows the herd rather than dispersing it — the same spike with soft edges.
A conforming implementation that drops the jitter still satisfies every other
line of this table and is still wrong.
Q-063 and Q-066 bound different failure shapes and neither is redundant —
the same relationship Q-058/Q-059 have. Whichever is reached first abandons the
room:
Store failure mode
What bounds it
Why the other does not
Fast (connection refused)
Q-063 — 12 attempts
Expected elapsed is ~91 s, inside Q-066
Slow (connection timeout)
Q-066 — 120 s
12 attempts × a 30 s timeout is ~6 min of waiting
The ~91 s figure is the sum of the expected draws, which under full jitter is half
each cap: (0.5 + 1 + 2 + 4 + 8 + 16)/2 + 5 × 30/2 s. Raising Q-063 far enough
that its expected elapsed exceeds Q-066 would make it inert against a
fast-failing store; lowering Q-066 below that figure would make Q-063 inert
against every store. Neither may be tuned without recomputing the other.
Q-067 is not a state-preservation window, and this is the distinction that
makes it necessary. ADR-072 holds a Dormant room’s activation to protect T0 and
avoid replay. An abandoned room has no T0 to protect. Q-067 exists so that
re-activation does not clock a fresh hydration attempt per inbound call — without
it, callers addressing an unreachable room drive the retry rate instead of the
room, and the backoff these quantities describe is bypassed entirely from outside.
Q-067 at zero does not disable a safety margin; it deletes the backoff.
What is ruled versus what is still assumed. The backoff shape — exponential,
full jitter, Q-065 base doubling to a Q-064 ceiling — is the ruled part and the
part the tests enforce. Q-063 and Q-066 are a policy choice about how long a
database outage is tolerated before a room stops trying, not a measurement, and
two minutes is chosen to ride out an ordinary failover without retrying an
unreachable store forever. They are marked Normative because a ruling set them, per
this table’s own status definition — not because an outage-duration
distribution has been measured. The measurement that would confirm or move them is
observed failover duration in production, and there is no production yet.
Enforcement:HydrationBackoff.CapFor/Next (the draw, asserted as a
distribution in HydrationBackoffTests rather than observed as a timing);
RoomGrain.HydrateAsync (the two abandonment conditions);
RoomHydrationOptionsValidator (a policy that could not run is refused at start-up).
Geometry cost — Q-015b’s ⊙3 refuted by measurement (2026-08-02)
Rule 2 of this table says ⊙ entries are falsifiable claims, published in that
form deliberately. This one has now been falsified, which is the mechanism
working rather than failing.
Measured per fog-of-war advance over one 256 × 256 chunk (Q-010/Q-011),
converted to this table’s units by Q-001 (20 advances per room-second, one
observer advancing one chunk every tick):
Dynamic occluders
Per advance
ms / room-second
vs Q-015a = 300
0
0.27 ms
5.4
ok
32
12.14 ms
242.8
approaching
128
20.01 ms
400.2
over
512 (Q-012 ceiling)
41.71 ms
834.2
2.8× over
For comparison, on the same host: one LOS ray is 0.4 µs at any occluder count, a
2,048-ray batch is 0.57 ms, and a 121 × 121 A* is 0.58 ms. (“At any occluder
count” was re-measured on 2026-08-13 and is withdrawn — a full-traversal ray is
2.8 ns at zero occluders and 871 ns at Q-012’s 256. The comparison this sentence
draws survives it by four orders of magnitude either way; see “The geometry crate
re-measured”.) Fog advance is the
only geometry operation whose cost is architecturally significant, and it is the
one Q-015b was estimating.
Q-015b is not simply replaced with a number, because the quantity is
configuration-dependent and one input is missing. The reference room is defined
in this table as 50 viewers with 200 entities per interest set; it does not
state a dynamic occluder count, and the table above shows the answer moves by two
orders of magnitude across that axis. Deciding the reference room’s occluder
count is a product question, not a measurement, and until it is answered
Q-015b has a measured curve rather than a measured value.
What is already settled is that ⊙3 is wrong by roughly two orders of
magnitude, and that Q-015a’s isolation ceiling is breached at 128 occluders by
a single observer — well below Q-012’s permitted 512. Either the mask cadence
ladder in §5.3 is not optional for ordinary rooms, or Q-012 is too permissive
for per-tick advancement, or Q-015a is set too low. That is a ruling, not a
measurement, and it is now the blocking one.
ADR-033 is what kept this from being an incident. Because geometry is
dispatched fire-and-forget and consumed a tick later, a room carrying 41.71 ms of
advance per tick was measured with a 28 µs tick body and zero overruns — the
shortfall appeared as MaskStaleness, exactly as §5.2 specifies. The estimate
being wrong by 278× did not stall a single tick.
Reference-point table (readability mitigation)
ADR-045 acknowledges that citing a Q-ID instead of a number costs skim-readability, and requires a reference-point table to mitigate it. These are the five most frequently cited scenarios, with the numbers and IDs side by side:
Reference room (50 viewers, 200-entity interest set per viewer)
Segment B measured 0.675ms against a 5ms budget (2026-08-13, BENCH-03 — the ⊙3.04ms derivation was 4.5× high); marginal cost 12.43µs per viewer (Q-110); geometry isolation ceiling 300ms (Q-015a); Q-015b’s expected ⊙3ms was refuted by measurement on 2026-08-02 (a single fog advance at Q-012’s ceiling is 41.71ms = 834ms per room-second); Q-016’s ⊙0.08 cores keeps its ⊙ and loses its explanation — it gave Segment B ⊙95% of the total and Segment B measures at 17% of it
Visibility memory
64 MiB per activation (Q-009); chunk 16 KiB (Q-010) = 256m × 1m cells (Q-011); the 50-viewer reference case (four parties plus the Keeper, ~16 resident chunks) is about 1.3 MiB
C-Companion threshold ⊙8 inches (Q-045, awaiting measurement); touch target 44pt (Q-044); asset tiers are decided by ADR-005’s 2D bake obligation
Rules of use
No bare numbers in the whitepaper — cite a Q-ID. A CI lint detects them by grep (allow-list: version numbers, years, and this table’s reference-point section).
A ⊙-marked entry must not be cited as normative. They are falsifiable claims, published in that form deliberately — a concrete number that can be overturned is more useful than an unfalsifiable qualitative allocation. Until the corresponding benchmark completes, any normative sentence depending on one must state that dependency.
A derived entry must record its formula. A “derived” value without one is a normative value wearing the costume of a derived one — which is exactly the shape of the error in Q-016’s original definition.
Register a new number here before writing it into a document, not the other way round. The other way round is the cause of the entire Q-036…050 section.
A pending entry has a deadline for disposition. A bound that is pending forever is operationally identical to no bound at all — the only difference is that the document looks more responsible.
Outstanding inventory
Category
Entries
What it blocks
Awaiting measurement (⊙ → normative)
Q-002, Q-005, Q-006, Q-008, Q-014, Q-016, Q-019, Q-026, Q-032, Q-033, Q-045, Q-080, Q-081, Q-085 — 14. Q-003 and Q-007left this row on 2026-08-13: BENCH-01, BENCH-02, BENCH-03 and BENCH-05 ran (ADR-177)
The rig exists — tests/core/Descent.Benchmarks, core/Descent.Geometry/benches, apps/vtt-frontend-client/bench, run weekly by .github/workflows/perf.yml. Nine of the fourteen are not blocked on a BENCH item at all and each now names what it is actually blocked on: Q-002/Q-005/Q-008 on Orleans message dispatch, which needs a live silo and a load generator (Q-112); Q-006 on the housekeeping messages other than the lease sweep; Q-014 on nothing measurable — it is an SLO rather than a cost; Q-016 on a fog-budget utilisation from real play; Q-019 on I/O; Q-026 on S6; Q-032 on a zh-TW corpus and a subsetter, neither of which is in this repository; Q-033 on real hardware — the Guardrail 7 low end, an integrated-GPU laptop and a mid-range tablet on a shared memory pool, since no web API reports resident VRAM; Q-045 on real devices, because inches need a device’s DPI; Q-080 on Studio; Q-081 on a product decision; and Q-085’s benchmark is not a BENCH item — it is the custom-asset size distribution, measurable from production upload telemetry rather than from a rig
Measured, awaiting a ruling
—
Empty as of 2026-08-06, and this row was stale for two days.Q-015b’s question — which of the mask cadence ladder, Q-012 or Q-015a had to move — was ruled on 2026-08-04: option (a), the ladder slows (Q-068…Q-071 ×1.5), Q-012 stays at 256 because a ~112-occluder ceiling would be met by authoring rather than by abuse, and permeability ships. The row survived because the ruling was written into the Q-015b section and not back into this inventory
Pending values (normative but valueless)
Q-024 (bytes), Q-025, Q-027, Q-031, Q-034, Q-035, Q-047, Q-048, Q-049, Q-050, Q-051, Q-052, Q-053, Q-054, Q-055, Q-072, Q-073, Q-074, Q-078, Q-079, Q-082, Q-086, Q-087, Q-088, Q-089 (added 2026-08-08) — 25. Q-075 and Q-076left this row: both were measured/derived on 2026-08-04 (44 and 6) and are Normative. Q-026, Q-033, Q-080 and Q-081 carry both markers and are counted once, in the ⊙ row above
The enforceability of each owning ADR. Q-056 was measured on 2026-08-02 and has left this row (32,768; see §Q-056). The absence of Q-033 and Q-050 means the corresponding protections do not in fact exist, which ranks them above the rest. Q-051 / Q-052 decide what “stepping away briefly” actually feels like, and the two are coupled. Q-075 blocks ADR-091 from shipping — permeability must not go live before it is re-measured, or the token bucket admits advances at a cost that no longer holds. While Q-076 is undecided, ADR-094 clause 6 is “described but unenforced” — the default policy bounds only how much a single opposed roll discloses, and bounds the rate not at all
CI lints (ADR-045 Enforcement)
Bare-number lint, ADR Status / reverse-link lint, normative-sentence enforcement-point lint
All three are implemented in tools/ (2026-08-01). Recording them as “blocked until project CI exists” was a misreading — all three checks defined in ADR-045’s Enforcement field operate on the whitepaper text; what needs a build is running them automatically per commit, not writing them
Re-measured 2026-08-06, and the figures below are the 2026-08-01 baseline rather than the current state. The lint now reports 129 unit-carrying quantities, 26 in a paragraph or table citing a Q-ID, and 22 distinct Q-IDs cited — against 114 / 1 / 5 when this note was written. So the uncited count is 103, not 113, and the number of distinct IDs actually appearing in the whitepaper has gone from five to twenty-two. The improvement was real and nobody recorded it, which is why docs/README.md was still quoting 113 five days later. The disposition question below is unchanged; only its size is.
What the bare-number rule actually measures (tools/bare_number_lint.py, 2026-08-01 — historical). Rule 1 above requires the whitepaper to cite a Q-ID throughout. In fact: the document carries 114 quantities with units, of which 113 sit in a paragraph or table citing no Q-ID at all; only 5 distinct Q-IDs appear anywhere (Q-020, Q-032, Q-051, Q-052, Q-053) against this table’s 55 entries. The rule has never been applied to the whitepaper body.
Rewriting all 113 is not obviously the right disposition: 33 of them sit inside §5.2’s Table A / Table B, whose entire purpose is to show their own arithmetic (0.25ms + 0.40ms + … = 3.04ms). Replacing the intermediate values of a derivation with Q-IDs would make the derivation impossible for a reader to check — that is, executing the rule literally would destroy the very auditability it exists to protect.
This is therefore a matter awaiting a ruling, not a cleanup. There are at least three possible dispositions: (a) require citation only for normative and ⊙ entries, exempting derivation tables and explanatory arithmetic; (b) require each paragraph that introduces a quantity to cite a Q-ID at least once, with repetitions exempt; (c) keep the rule as written and accept a large-scale rewrite. Until it is ruled on, the lint defaults to measurement mode (always exits 0) and only --strict blocks.
Q-034 and Q-049 split into two bounds — 2026-08-06 (ADR-105)
ADR-087 left both pending because “a quota bounding legitimate high volume and abuse with one
number encodes a compromise in a constant nobody will later recognise as one”. That objection was
correct and one step short: it is equally a reason to have two numbers.
Abuse bound
Cost bound
Protects
other tenants, and the disclosure surface
the bill
Derived from
the shape of legitimate behaviour
measurement, then commercial choice
Scales with entitlement tier
never
yes
Blocked on the tier model
no
yes
Q-049 = 2 concurrent checkouts per account, across all rooms. Normative, decided — not
⊙, because it estimates nothing. A checkout is Keeper-driven with a preview to read (ADR-075
builds the candidate outside the refusal window precisely so a human can look at it). Two
covers one person running two tables; fifty-room scripted traversal — §10.3 face 6 — is
infeasible. Q-013 bounds the per-room rate and cannot see across rooms.
Q-034 = 1 concurrent export per account. Normative, decided. An export is an offline job
delivered by a short-lived link; there is no user-visible benefit to two at once, and
concurrency is the term that turns a daily allowance into a burst against the shared pool.
Q-034’s daily allowance stays Pending, and its absence now blocks nothing but a price
list. With concurrency at 1 the burn rate is bounded by the offline pool’s own capacity
regardless of the daily figure.
An abuse bound that money can raise is a cost bound wearing the wrong label, and the tenant
it protects is not the one paying. That request will be made; this row is the answer to it.
Knowledge corpus and CDC bounds (Q-103…Q-105, Q-107) — added 2026-08-12, ADR-168 / ADR-169
ID
Name
Value
Source
Status
Q-103
KNOWLEDGE_SEARCH_LICENCES_MAX
32
ADR-168; one entitlement read per named licence against the Marketplace replica
Normative
Q-104
SEARCH_TENANT_TOKEN_TTL_SECONDS
300
ADR-168; the window in which a revoked licence stays searchable
Normative
Q-105
KNOWLEDGE_CHUNK_TEXT_MAX_CHARS
8,192
ADR-168/169; one outbox payload, and therefore one write-ahead-log record
Normative
Q-107
KNOWLEDGE_INDEX_BATCH_MAX
512
ADR-169; chunks held by the replication consumer before a flush
Normative
All four are refusal choices rather than estimates, the same category as Q-095 and
Q-096: each says what this platform will not accept, not what something is expected to cost.
None is ⊙ and none is waiting on a benchmark.
Q-104 is the one that is not a bound but a lag, and calling it a TTL hides that. The search
engine validates a token; it does not re-ask whether the licence is still held. So this is the
interval for which a revoked entitlement remains searchable — a hole ADR-168 bounds rather than
closes, and the honest way to read the row is “how incomplete a takedown is allowed to look”.
Shortening it costs one token mint per interval per active searcher; lengthening it widens the
hole. ADR-097’s rule that nothing may be cached beyond a room’s lifetime is not violated and
is not satisfied either — the token is a cache, and this number is its size.
Q-103 bounds amplification, not correctness. Each named licence is one keyed read on the
replica (§2.5 grants a lookup, never an enumeration), so an unbounded list turns one
authenticated request into a burst against the database the whole of §2.5 exists to protect.
Refused rather than truncated: a silently shortened list mints a token omitting a licence the
caller does hold, which surfaces to a player as “my rulebook is missing from search” and is
undiagnosable from the client.
Q-105 reaches further than it looks. It bounds the chunk, therefore the outbox row,
therefore one write-ahead-log record, therefore the memory one replication message costs to
decode. Refused rather than truncated because §7.2 promises “exact text citations” and half
a chunk is a citation that stops mid-sentence.
Q-107 only binds a backfill. Ordinary traffic flushes at its transaction’s commit, which is
also the only point the replication slot is advanced; this ceiling exists so that one
INSERT … SELECT over a whole corpus cannot be held entirely in the consumer’s memory before
anything is indexed. Flushing early costs at-least-once delivery of the early part if the
consumer then dies, which the index tolerates because an upsert is keyed on ChunkId.
Enforcement (ADR-045):KnowledgeSearchOptionsValidator and KnowledgeCdcOptionsValidator
refuse a host that has not set Q-103 or Q-107, and MeilisearchOptionsValidator refuses a
non-positive Q-104 — none of the three ships a default, following §3.1’s rule for the geometry
limits. Q-105 is enforced in KnowledgeChunkCodec on both sides: Encode throws and
TryDecode refuses, so a payload written by an older version or by hand cannot put an unbounded
string into the index. KnowledgeChunkCodecTests.Text_over_Q_105_is_refused_on_the_way_in_and_on_the_way_out
and its ..._at_exactly_Q_105_is_accepted partner pin the boundary in both directions.
AI session bounds (Q-106, Q-108, Q-109) — added 2026-08-12, ADR-170
ID
Name
Value
Source
Status
Q-106
AI_SESSION_HISTORY_TURNS_MAX
64
ADR-170; turns retained in a session’s persisted state
Normative
Q-108
AI_PROPOSAL_HIT_POINT_DELTA_MAX
9,999
ADR-170; the largest magnitude a decoded proposal may carry
Normative
Q-109
AI_PROPOSAL_TEXT_MAX_CHARS
512
ADR-170; one prompt, one ability name, one narration
Normative
Q-106 counts turns and not exchanges, so one ask and one answer are two. The session
refuses at the ceiling rather than evicting its oldest turn, and the direction is the decision:
silent eviction changes what the model was told without the game master knowing, so the same
question starts producing a different answer because context they remember giving is gone. A full
session says so, and starting a new one is a visible act.
Q-108 and Q-109 bound a language model’s output, which is the least trustworthy input in
this system. Both are refusals rather than clamps: a model that emits a delta of two million
has misunderstood the request, and clamping it to Q-108 would show a game master a plausible
proposal derived from an implausible one. Q-109 also bounds what a turn costs to persist, since
the history is grain state written on every deactivation.
What these do NOT bound, stated because the gap is the important part: there is no rate limit
and no token quota. §10.3 names per-room and per-player daily consumption quotas as the defence
against prompt spamming. One in-flight turn per session and Q-106 together bound concurrency and
conversation length; neither bounds how often a player may ask. With the offline mock provider the
cost of that gap is CPU. With a real provider it is money, and ADR-170 records the quota as
something that must exist before one is configured.
Enforcement (ADR-045):AiSessionOptionsValidator refuses a host that has not set Q-106;
AiSessionGrainTests.A_session_at_Q_106_refuses_rather_than_evicting_its_oldest_turn asserts both
halves — the refusal, and that nothing was dropped to make room. Q-108 and Q-109 are enforced
in AiCombatActionCodec.TryDecode, with A_delta_at_exactly_Q_108_is_accepted_in_both_directions
pinning the sign as well as the magnitude.
The measurement rig’s first results (Q-110…Q-112) — added 2026-08-13, ADR-177
BENCH-01, BENCH-02, BENCH-03 and BENCH-05 have run. They were outstanding from W1 and this
table has named them as the blocker on sixteen ⊙ rows since it was created. What follows is what
they measured, what it settles, and — at greater length, because it is the part that matters —
what it does not.
The machine, stated first, because every absolute below is a property of it. AMD Ryzen 7
7735HS, 8 physical cores, Windows 11 26200, .NET 10.0.10 (x86-64-v3 RyuJIT), Release,
BenchmarkDotNet 0.15.8. tools/perf-baseline.json gates ratios and publishes absolutes for
exactly the reason Q-075’s entry gives: the multiplier transfers between machines and the
milliseconds do not.
§5.2 Segment B, term by term (50 viewers, 200-entity interest set)
Term
Derived
Measured
vs derived
AOI query + per-viewer delta comparison, every slot changed
⊙0.65 ms
0.342 ms
0.53×
— the comparison alone, nothing changed
⊙0.40 ms
0.092 ms
0.23×
FlatBuffers assembly, one packet per viewer
⊙2.00 ms
0.354 ms
0.18×
cartridge MessagePack sub-payload, shared (D-3)
⊙0.30 ms
0.034 ms
0.11×
per-silo grouping (ADR-032)
⊙0.02 ms
not built
—
per-viewer digest hash (§10.4)
⊙0.02 ms
not built
—
outbound allowlist recomputation (§9.6.3)
⊙0.05 ms
not built
—
Segment B — the four implemented terms
⊙3.04 ms
0.675 ms
0.22×
Segment B — what AdvanceTickAsync’s own body runs
—
0.332 ms
—
The pre-registered criterion was “within 0.5×–3× of the analytical prediction counts as
confirmation; > 3× requires reallocating Table A’s budget”, and the result is outside it on the
side the criterion did not anticipate. 0.22× is below the 0.5× floor. The budget holds —
0.675 ms against 5 ms is 7.4× headroom — and the derivation it holds by is wrong by a factor
of four and a half. Recorded as a miss rather than as a pass, because the band was two-sided and
only one side was given an action, and a criterion checked only in the direction somebody feared
is not a criterion.
Three of the seven terms measure nothing because the work does not exist. Verified
2026-08-13 by search across apps/vtt-backend-silo and modules/: RoomSnapshotDispatcher
sends per viewer to that viewer’s own connection with no bucketing step, there is no XxHash3
and no digest field anywhere in the silo, and §9.6.3’s pairwise mask is not implemented. Their
derived total is ⊙0.09 ms, 3% of Segment B. They are named and left unmeasured rather than
approximated (P7): a stand-in would publish a figure describing code nobody has written, and
the substitution would be invisible in the row above.
Two Segment B figures, because Table A budgets MAILBOX OCCUPANCY and the derivation costed the
WORK.RoomGrain.ReplicateToViewers ends at SendDeltas/SendSnapshots, both
_ = …Async(…); the wire encode happens inside the transport sink and the cartridge payload
rides ADR-092’s decoupled path. So the tick’s own frame runs 0.332 ms of the 0.675. Whether
the fire-and-forget continuation still occupies the activation is NOT settled here — Orleans
resumes a grain’s continuations on its own scheduler, and how much runs on the caller’s stack
depends on whether the transport’s ValueTask completes synchronously. That needs a live silo,
and it is recorded as open rather than assigned to a segment by assumption.
Q-110 — Segment B’s per-viewer marginal cost
ID
Name
Value
Source
Status
Q-110
SEGMENT_B_MARGINAL_US_PER_VIEWER
12.43 µs (as derived) / 6.06 µs (mailbox body), at a 200-entity interest set
Measured 2026-08-13, SegmentBBenchmarks
Normative
This is the number §5.2 needed and did not have. The whitepaper’s 50 seats are a derivation
premise, and §5.2 says in as many words that raising the count is a trade — “Raising the seat
count therefore requires lowering the per-player rate or making intent handling cheaper — the
trade is explicit rather than discovered under load” — and then never fixes the seat count,
while §4.4’s admission control weighs rooms by viewer count against a quantity that did not
exist.
Measured across four seat counts, so the line is checkable rather than assumed. At 1 / 8 /
25 / 50 viewers Segment B is 61.5 / 152.7 / 355.1 / 674.7 µs. The secant from 8 to 50 gives
12.43 µs per viewer; a least-squares fit over all four gives 12.46 µs with an intercept of
49.3 µs and predicts the 1-viewer point to within 0.4%. The relation is linear over the
measured range, which is what makes the next entry arithmetic rather than extrapolation.
The fixed term is the interesting half. ~49 µs of Segment B does not scale with viewers at
all — it is WorldDeltaAssembler.Project, one pass over the room per tick — and it is why a
one-viewer room costs 61 µs rather than 12.
Q-111 — the seat count Segment B’s own budget admits
ID
Name
Value
Source
Status
Q-111
SEGMENT_B_ADMITTED_SEATS / _ENTITY_BOUND
398 seats / 200 entities
Derived: see the formula below
Derived
Registered as a pair, because a seat count with no entity bound is not a bound. Segment B
scales with viewers and with what each viewer is shown; 398 is the figure at §5.2’s
200-entity interest set and it means nothing detached from it.
It is NOT the room’s seat ceiling and must never be cited as one. It is what one term’s
budget admits. Three constraints sit between it and a real ceiling and none was measured by this
round:
Orleans message dispatch.Q-112 measures the application half of an intent and not the
grain call around it. At 398 seats and Q-008’s 10/s that is ~3,980 messages per second into
one single-threaded activation, and nothing here says that is possible.
Bandwidth.Q-056’s derivation puts §5.2’s reference interest set at 8,068 bytes, so 398
viewers at Q-001’s 20 Hz is ~64 MB/s per room, against a budget ADR-014 owns and this round
did not evaluate.
Room size against interest-set size.WorldDeltaAssembler.Assemble iterates every slot
the room has ever assigned, with the AOI test inside that loop rather than in front of it —
so the delta pass is O(viewers × room) where the derivation assumed O(viewers × interest set).
The two agree only when the room is the interest set, which is the shape of the reference
room and therefore the shape this figure was taken on. A room holding 2,000 entities of
which each viewer sees 200 is not covered by any number here.
The seat-count / intent-rate relation, carried here so the next person can see the price
rather than rediscover it. §5.2’s trade is seats × intent rate × per-intent cost ≤ Q-005.
With Q-112 measured at 0.394 µs, the application half of that product at 398 seats and 10/s is
78 µs against Q-005’s ⊙15 ms — four orders of magnitude of slack, on the half that was
measured. The trade §5.2 describes is therefore not paid in intent handling at any seat count
this rig can reach; it is paid in whatever the unmeasured dispatch costs.
Q-112 — what one player intent costs in the application layer
D-4 assumed ⊙0.3 ms per intent and the application half is 0.394 µs — 760× cheaper. The
decode dominates it: 363 ns of the 394 is SubmitIntentCodec.TryDecode, and the whole lease
protocol is 21 ns to grant or renew and 7 ns to refuse. Lease contention is cheaper than
granting, because a refusal finds the blocking grant and stops.
Normative as a lower bound, which is a status this table has not used before and needs. An
intent is a grain message and its true mailbox occupancy is this plus Orleans’ own dispatch —
deserialisation, the activation’s scheduler, the turn boundary. Standing a TestCluster up
inside a BenchmarkDotNet harness measures the test host rather than a deployed silo, so that
half is not measured and not estimated. Marking the row Normative outright would claim a
completeness it does not have; marking it ⊙ would claim nothing was measured. The bound is
real, and it is a bound.
What would close it is the load-generation tool §14.8’s T4 tier is for: a real silo, real
connections, Q-008’s rate at Q-111’s seat count, and tick jitter observed rather than
computed. It does not exist. Named here rather than left implicit, per
docs-and-adr-workflow.md §4.
BENCH-01: Q-007’s ceiling of 1,000 holds, with 4.5× to spare
Effects in the batch
premise validation + event construction
apply
per Effect
1
471 ns
238 ns
709 ns
100
36.0 µs
26.4 µs
624 ns
1,000 (Q-007)
382.0 µs
288.4 µs
670 ns
The criterion was “Q-007; if > ⊙3 µs, the ceiling of 1,000 must be lowered”. Measured
0.67 µs, so it is not lowered. At the ceiling a full batch costs 0.670 ms against
Q-003’s Segment A budget of ⊙3 ms — 4.5× headroom, on the same order as Segment B’s.
The batch does not amortise, and that is worth knowing before someone raises Q-007. The
unit cost is 709 / 624 / 670 ns across three orders of magnitude of batch size, so the two
staging dictionaries and RoomAuthorisedSchema.Compose are not what a batch is paying for —
the cost is per proposal and scales cleanly. Q-007 can therefore be reasoned about
linearly, which the derivation assumed and nothing had checked.
The number this row does NOT contain is the channel write. W1 defines the unit as
“premise validation, event construction, channel write”, and the third is
RoomGrain’s _outbox — a List<DomainEvent> add behind a capacity check, inside a grain a
BenchmarkDotNet harness may not host for the reason Q-112 gives. An add to a grown list is
nanoseconds against a 670 ns unit, so the omission is small; it is stated because “small” is
a judgement and the row would otherwise read as complete.
The allocation is the part that should be watched. A full 1,000-Effect batch allocates
1.66 MB and lands 9.8 Gen1 collections per thousand batches. Q-007 is a per-tick
ceiling, so a room that reaches it is asking for 33 MB/s of garbage from Segment A alone,
on top of Segment B’s 28 MB/s. Neither figure had ever been taken.
What BENCH-02 decided about D-2, and how close it came to the other answer
Viewers
contiguous array
Dictionary of reference-typed transforms
ratio
1
678 ns
2,456 ns
3.62×
8
5.12 µs
19.69 µs
3.84×
25
16.03 µs
62.55 µs
3.90×
50
32.24 µs
147.38 µs
4.57×
D-2 stays normative, and the margin is 21%. The pre-registered criterion is explicit: the
derivation predicted a 5×–25× gap, and “if the gap is < 3×, W1/D-2’s normative requirement
can be demoted to a recommendation”. Measured 3.62×–4.57×: above the demotion threshold,
below the predicted band. Both halves are recorded because both are true — the rule survives,
and the number it was written on was optimistic by roughly a factor of two.
What the ratio buys, in Segment B’s own units: at 50 viewers the dictionary layout costs
115 µs more per tick, which is 2.3% of the 5 ms budget rather than the “delta pass alone
consumes the whole segment” the derivation predicted. That prediction assumed ⊙20–50 ns per
comparison against ⊙2 ns; the measured contiguous pass is 32.24 µs for 10,000 slot comparisons
= 3.2 ns, and the dictionary is 14.7 ns. The per-comparison figures were the right shape
and the dictionary end was 2–3× pessimistic.
The dictionary arm is built to be as good as a dictionary gets, and that matters for a
result this close to a threshold: the same EntityId key the production slot map uses, pre-sized
to its final capacity so neither arm pays a rehash, entries allocated in slot order. What it
does not get is the half of ADR-023’s rule that says “or boxed values” — the transform is a
class, so a comparison is a hash lookup and then a pointer chase. A real deployment accumulates
those entries as players join over a session, so the heap layout gets worse rather than better.
Enforcement point (ADR-045):Descent.ArchitectureTests forbids the production baseline
from using a dictionary; BaselineLayoutBenchmarks is where the rejected design is allowed to
exist, and tools/perf-baseline.json gates the ratio at ≥ 3.0 — the demotion threshold
itself — so a change that closed the gap turns the T4 job red rather than quietly making a
normative rule pointless.
D-3 measured: what sharing the cartridge payload actually saves
Viewers
shared once per entity (D-3)
re-serialised per viewer
ratio
1
35.0 µs
35.3 µs
1.01×
8
35.0 µs
298.9 µs
8.55×
25
33.7 µs
852.5 µs
25.3×
50
33.6 µs
1,701.6 µs
50.7×
The shared arm is flat and the other is linear, which is the measurable form of “O(entities),
not O(viewers × entities)”. §8.2 states that as a normative requirement; this is the first
time it has had a price. At 50 viewers D-3 saves 1.67 ms per tick — a third of Segment B’s
entire budget — which is more than every implemented term of Segment B put together.
It is also the rule most likely to be broken by accident, because breaking it looks like
moving one call inside one loop. The ratio is gated in tools/perf-baseline.json for that
reason.
The geometry cost §5.2 Table A has no row for
ADR-033 moved geometry out of the mailbox and §5.2 budgets the consequence in Table B, per
room-second. Table A gives the tick nothing at all for geometry, and that is not quite
right: RoomGrain.AdvanceTickAsync crosses the P/Invoke boundary’s near side twice on every
tick — Stage 2 copies the occluder set into a job and enqueues it, Stage 3 copies Q-010’s
16 KiB mask out of the published slot. Neither runs native geometry; both are on the mailbox.
0 occluders
256 occluders (Q-012)
dispatch + read the published mask (on the mailbox)
487 ns
517 ns
— the 16 KiB mask read alone (Q-010)
217 ns
161 ns
full FFI round trip, dispatch → published (off the mailbox)
0.264 ms
24.24 ms
The tick pays ~0.5 µs per observer dispatch, and it is not worth a Table A row — but the
absence should be a stated absence rather than an oversight. 0.5 µs against Q-002’s 28 ms
is 0.002%, and it is flat in Q-012 because the copy is dominated by the fixed 16 KiB rather
than by the occluder set.
The round-trip row is a LATENCY, not a cost, and nothing in Table A may be compared against
it. ADR-033’s whole point is that the room does not wait for it. It is here because it is
the first figure taken through the P/Invoke boundary the silo actually uses, and because it
corroborates the crate’s own numbers from the other side: 0.264 ms at zero occluders against
the registry’s 0.26 ms, and 24.24 ms at Q-012 against the 29.37 ms taken on the 2026-08-02
box — the same shape, on a machine Q-075’s entry already records as faster.
This row is here because the first version of it was wrong and plausible. The round-trip
arm originally waited for any published mask rather than the one it had just dispatched, so
it returned on the previous iteration’s result and reported 440–515 ns — within a hundred
nanoseconds of the near-side row above. Two benchmarks measuring different things do not agree
to that precision, and that is what gave it away rather than any assertion. Corrected before
anything was published; recorded because a plausible-looking number is exactly what this rig
exists to stop the corpus acquiring.
The allocation figure nobody had, and it is the one worth watching
Segment B’s four implemented terms allocate 1,418,208 bytes per tick at 50 viewers, which at
Q-001’s 20 Hz is 28.4 MB/s per room. The mailbox body alone is 937,808 B per tick, 18.8 MB/s.
⊙ was never the only thing missing from these rows; a garbage rate was. Nothing in §5.2
budgets allocation, and a Gen0 collection landing inside a tick is exactly the jitter Table A’s
≥22 ms Reserve row exists to absorb — so the figure belongs beside the timings rather than in a
footnote. Recorded rather than acted on: whether 28 MB/s per room matters depends on rooms per
silo, which is Q-016’s question and is not settled here.
The two largest contributors are both known and both fixable in principle.PerViewerSnapshotCodec.Encode constructs a fresh 8 KiB FlatBufferBuilder per call where
WorldDeltaCodec reuses one, and WorldDeltaAssembler.Assemble allocates three List<>s per
viewer per tick. Neither is a defect today and neither has a Q-ID; they are named so that a
future change has somewhere to start.
The geometry crate re-measured, and what reproduced — 2026-08-13, ADR-177
Four figures in this table were taken once, by hand, on one machine, and have been load-bearing
ever since. core/Descent.Geometry/benches/ re-takes all four under criterion, on the pinned
1.97.1 toolchain, with confidence intervals. Three reproduce. Two do not, and one of those is a
claim rather than a number.
Same box as the .NET figures above — AMD Ryzen 7 7735HS, --release, rustc 1.97.1. It is
the same machine class Q-075 was measured on, which is the first thing the run
established rather than assumed: Q-075’s entry records the impermeable advance at 12.99 ms
where 2026-08-02 recorded 29.37 ms, and this run measures 13.065 ms. Two independent
measurements of the same quantity, 0.6% apart, taken eight days and one harness apart.
Reproduced
Quantity
Recorded
Re-measured
Impermeable fog advance at Q-012 = 256
12.99 ms (Q-075’s box)
13.065 ms
0.6% apart
A* worst case at Q-058 = 4,096, region exhausted
6.1 ms
5.21 ms
on a box this run shows to be ~1.2× faster
ADR-143’s WebAssembly slowdown, compute-bound
5.32×
5.47×
2.8% apart
ADR-143’s ratio is the one that most needed this. It declined the universal WebAssembly
geometry core — and with it the deletion of the C ABI, the [LibraryImport] surface and the
only AllowUnsafeBlocks project in the tree — on a single measurement of 5.32×. It now has a
confidence interval and a weekly re-run. Both workloads are reported because they differ:
compute-bound is 5.47× (native 2.68 µs, wasmtime 14.67 µs) and call-bound is 5.76× (20.9 ns
against 120.4 ns). Q-015a’s budget is compute, so 5.47× is the figure that divides it; the
call-bound arm exists because a 20 Hz dispatch loop pays that cost on every query and reporting
only the first would hide a real term.
The two hosts are asserted to AGREE before any ratio is reported. ADR-017 makes a
disagreement the defect rather than the benchmark’s problem, and a speed comparison between
hosts that answer differently means nothing.
Not reproduced, and both are worth acting on
1. The fog-cost model’s coefficients do not transfer between machines, and its intercept is not
the zero-occluder cost. This table publishes cost ≈ 0.26 + 1.81·√n ms and says it “may be used
to interpolate an untested occluder count” because the square root is BVH traversal rather than
a convenience fit. The shape reproduces and the coefficients do not. On this box:
Occluders
0
32
128
256
measured
0.215 ms
6.475 ms
9.377 ms
13.065 ms
published model, rescaled to this box
0.115 ms
4.64 ms
9.17 ms
12.90 ms
A fit over this box’s own points gives cost ≈ 2.68 + 0.633·√n ms, within 5% at every point —
and its intercept is 2.68 ms against a measured zero-occluder cost of 0.215 ms, an order of
magnitude apart. The published model set its intercept to the zero-occluder reading and fitted
only the coefficient, which happened to work on one machine.
The consequence is narrow and should be stated narrowly: interpolate within a machine, never
across one. The √n shape is confirmed where it matters — 128 and 256 land within 2% — and the
low end does not follow the same line here. Nothing derived from this model changes, because
everything derived from it (Q-012’s reduction, the Q-068…Q-071 ladder) used the 256-point
measurement directly rather than the interpolation.
2. Q-075’s permeable multiplier re-measures at 1.25×, not 1.4×.
Occluders
impermeable
permeable
ratio
recorded 2026-08-04
16
3.177 ms
3.170 ms
1.00×
1.1×
64
8.829 ms
9.657 ms
1.09×
1.2×
128
9.336 ms
11.612 ms
1.24×
1.3×
256 (Q-012)
13.604 ms
17.052 ms
1.25×
1.4×
The ladder is NOT re-derived on this, and the refusal is the decision.Q-075 = 44 ms was
set as 29.37 ms × 1.5, “taking the upper end because the conservative direction for a cost
is the larger one”, and on that basis Q-068…Q-071 were slowed by half — which cost §9.2’s
fog cadence, moved epic_presentation_smooth_fog.md’s crossfade from 200 ms to 300 ms, and
grew a recorded token/mask desync from 250 ms to 350 ms. At 1.25× the allowance would be
300 ÷ 36.7 = 8.2 → 8 advances per room-second rather than 6, and the ladder could be given
a third of that presentation cost back.
Two measurements of one ratio, 12% apart, is not enough to move a shipped ladder, and the
direction of the disagreement is the safe one: 44 ms is conservative, so nothing is currently
under-budgeted. What would settle it is repetition, which now exists — perf.yml re-takes
this weekly and tools/perf-baseline.json bands the ratio at 1.10–1.60, so a run outside that
is a finding rather than a shrug. Recorded here rather than acted on, because
re-deriving a ladder on a single run is the failure mode Q-058’s entry warns about in the
other direction.
The claim that is refuted rather than re-measured
§5.2 Table B says LOS is “0.4 µs per ray” and — in this table’s own geometry-cost section —
“one LOS ray is 0.4 µs at any occluder count”. The independence claim does not hold:
Occluders
0
256 (Q-012)
one unblocked ray crossing the whole chunk
2.8 ns
871 ns
311× apart, so “at any occluder count” is false, and the published 0.4 µs sits between the
two ends rather than describing either. The measured ray is the worst case by construction —
unblocked, so the traversal runs to completion, where a blocked ray short-circuits on its first
hit and measures the early exit. examples/wasm_vs_native.rs already draws that distinction for
its own two workloads; the LOS claim never did, and that is most likely where 0.4 µs came from.
Nothing rests on it, which is why this is a correction and not a finding with consequences.
The figure appears only in the sentence that calls LOS and A* “not significant by comparison”
with fog advance, and at 871 ns against 13 ms that comparison holds by four orders of magnitude
whichever end is quoted. The claim of independence is what is withdrawn.
Q-099 / Q-100 in a real browser — 2026-08-13, ADR-177. Still ⊙, and now for a different reason
The registry named the benchmark that would make these normative: “the same script run under
Playwright against the Guardrail 7 device matrix, reporting a distribution rather than a
best-of-five”. apps/vtt-frontend-client/bench is the first two-thirds of that sentence — a real
Chromium, a real crypto.subtle, the real wasm artefact, and a distribution.
Two runs, headless Chromium on this box, 100 batched samples each:
min
p50
p95
max
spread
crypto.subtle (Q-099)
0.171 ms
0.194 / 0.215 ms
0.40 / 0.47 ms
0.88 / 1.05 ms
366% / 410%
wasm decoder (Q-100)
4.74 ms
4.94 / 7.44 ms
8.7 / 9.9 ms
8.8 / 10.2 ms
80% / 73%
The ratio holds and is larger in a browser than in Node. At the minima it is 27.7×
against Node’s 19.5×; at the p50s, 23×–35×. ADR-153’s finding is structural and this
strengthens it: WebAssembly has no AES instruction in any shipped or proposed extension, so
RustCrypto’s fixslice software cipher is the best any wasm build can do, and a browser’s
crypto.subtle is not slower than Node’s in a way that closes the gap.
They stay ⊙, and the residual is now precise rather than general. The old reason was
“the run was Node and the claim is about a browser”. That is answered. What is left is two
things and both are in the table above: the spread, which is 366–410% on the fast arm and
makes a p50 unstable between runs on one machine (4.94 ms and 7.44 ms are the same benchmark
twenty minutes apart), and the device matrix, which is Guardrail 7’s and does not exist. The
minima are the stable statistic and are what the ratio above is taken at.
Timer quantisation was the first thing this measured, and it is worth recording. A browser
clamps performance.now() to roughly 100 µs unless the page is cross-origin isolated — a
Spectre mitigation — so the first run reported crypto.subtle at exactly 0.100 / 0.200 / 0.300
/ 0.400 ms: four values, all multiples of the clock, and a “distribution” describing the timer.
The suite now times batches of 100 and divides. tools/bench-asset-decoder.mjs’s Node figures
are not affected — Node’s performance.now() is not clamped — which is one more way the two
environments differ that the ⊙ note did not anticipate.
Q-033 is not advanced and the probe explains why rather than producing a number. What it
recorded on this run is the point: navigator.gpu is present and requestAdapter() returns
null, and WebGL2 reports ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device ...)) — a
software rasteriser whose “VRAM” is host RAM. No web API reports resident GPU memory at all,
deliberately, because it is a fingerprinting surface. Q-033 can only be measured by
allocating until something breaks on the hardware the answer is about, and that hardware is
Guardrail 7’s low end: an integrated-GPU laptop and a mid-range Android tablet on a shared
memory pool. A figure taken under SwiftShader would describe the runner and would be wrong in
the direction that looks safe.