R38 — The Mega-Epic technology evaluation (2026-08-11)
R38 — The Mega-Epic technology evaluation (2026-08-11)
What this round is. Eleven proposed technology adoptions, each evaluated by building it far enough to measure, then accepted, narrowed or refused on the measurement. The rounds before this one dispositioned findings in an existing corpus; this one dispositions proposals against it.
The rule that shaped every record below. A proposal is refused only with a number or a traced mechanism behind the refusal, never with an opinion — and where the measurement could not be taken in this environment, the record says so in those words rather than substituting a plausible figure (
P7).
ADR-153 — The asset decryption core stays on crypto.subtle; the WebAssembly decoder ships built, tested and off
Status. Accepted
Date. 2026-08-11
Supersedes / Amends / Depends-on. Depends-on 060 (session-scoped content keys, and the
ciphertext-only OPFS store whose contents this decodes); Depends-on 064 (the capability
injection point, which is why navigator.storage is not read by the store); interacts with
017, 144, 147.
Introduces. Q-098 (ASSET_CHUNK_PLAINTEXT_BYTES, Existing), Q-099
(CHUNK_DECRYPT_WEBCRYPTO_MS, ⊙), Q-100 (CHUNK_DECRYPT_WASM_SIMD_MS, ⊙).
Rights-holders. None. This decision retains no new class of data — it decodes bytes the
CDN already delivered under a key ADR-060 already governs.
Context
§6.1 specifies chunked AES-GCM-256 over 512KB chunks, decrypted “via WebCrypto”. The proposal
was to replace that with a Rust wasm32 core built with +simd128, on the expectation that
WebAssembly SIMD would beat the browser’s own crypto.
The core was built (core/descent-asset-decoder), and it is correct: NIST CAVP vectors pass on
the host target, the real .wasm passes the same corpus through wasmtime, and a third suite
drives it from the loader a browser worker runs. It also decrypts a chunk that
crypto.subtle sealed and vice versa, which is a genuine two-implementation interoperability
result over §6.1’s HKDF clause.
Then it was measured, and the premise did not survive.
The measurement
+simd128 works, and buys 5.3%. Against a build of the same source with the flag removed:
714 v128 opcodes of 17,235 (cargo run --example simd_census), 43,930 bytes against
56,220, and 4.738 ms against 5.012 ms per chunk with the spread inside each arm under 1%. LLVM
autovectorised the bitslice unaided — the census is dominated by V128Xor, V128And, V128Not
and the I64x2 shifts, which is exactly what fixslice is made of. Neither aes 0.9.2 nor
polyval 0.7.3 has a wasm32 backend (both gate on aarch64 and x86/x86_64 only), so none
of that came from a hand-written intrinsic.
And it is about 19.5x slower than crypto.subtle — 4.738 ms against 0.243 ms for one 512KB
chunk (tools/bench-asset-decoder.mjs, Node v24.18.1, one x86-64 machine; Q-099/Q-100
record the scope, including that crypto.subtle’s own spread is 35% and the best figure is
the one recorded, which makes the ratio a lower bound). At §9.6.1’s frame boundary that is
roughly 1 chunk per frame against ~34, which makes §9.6.2’s low-LOD placeholder the normal
case rather than the degraded one.
5% is the whole of what the flag can be asked for here, which is why it does not change the answer: the deficit is 1,950%.
The reason is structural and does not move. WebAssembly has no AES instruction in the MVP,
in fixed-width SIMD, or in relaxed-SIMD. crypto.subtle issues AESENC or AESE — one
instruction per round in silicon. A wasm module can only express AES as arithmetic. That is not
a gap a better crate closes.
A measurement error is recorded here rather than quietly fixed, because its shape is more
useful than its content. The first census reported zero vector instructions, and that number
reached a draft of this ADR as a structural finding. Cargo discovers .cargo/config.toml from
the current directory, not from --manifest-path, so a build run from the repository root
silently dropped +simd128 and wrote a different artefact to the same path — nothing
failed, the tests passed, and the census faithfully reported zero for the build it was handed.
It was caught by a four-kilobyte discrepancy in a log line, because
build-asset-decoder-wasm.mjs runs cargo with cwd set to the crate. This is
architecture-rules.md §9.4 in a new place: a boundary enforced by one tool’s file-discovery
rule is not enforced against a different invocation of the same tool.
Decision
crypto.subtleremains the decoder, as §6.1 already says. No whitepaper claim changes.- The WebAssembly core ships built and tested, behind
WASM_DECODER_PREFERRED = falseindecoder.ts— a committed constant with both arms under test today, the shapeWEBGPU_BACKEND_BUILTuses indegradation.ts. It is selected today only wherecrypto.subtleis absent. - The port is the definition, not either implementation.
decoder-port.tsis its own module and both decoders run one shared assertion suite. - ADR-060’s ciphertext-only property becomes structural.
SealedChunkcarries aunique symbolbrand with one constructor; the decoders return plainUint8Array. Writing plaintext to the OPFS store is now a compile error rather than a review comment (P3).
Alternatives considered and why rejected
| Alternative | Why not |
|---|---|
| Adopt the wasm core as the default anyway, for the threat-model gain | §6.1’s threat model is explicitly about cost, not prevention, and 19.5× of the streaming budget is the wrong price for moving an attack from “hook a global” to “walk a wasm heap”. Both remain possible for a determined extractor, which §6.1 already concedes |
Switch to ChaCha20-Poly1305, which genuinely vectorises on simd128 | It would make a slow path faster than a different slow path. crypto.subtle does not implement ChaCha20-Poly1305 at all, so the comparison is against JavaScript, not against the AES-NI path that already exists. It would also amend §6.1’s named cipher for a regression |
Hand-write v128 intrinsics for the bitsliced rounds and GHASH | Not evaluated, and the record says so rather than dismissing it. LLVM’s unaided autovectorisation already found 5.3%; a hand-written kernel could plausibly recover a further factor of two to four — and the target is 19.5×. A best case that still loses by 5× does not change any decision here, which is why the measurement was not taken |
| Delete the crate now that it is not the default | It is the only decoder available outside a secure context, it is the artefact the interoperability corpus is written against, and it is the thing that makes flipping the constant a one-line change rather than a rewrite. P8: the exit cost is recorded at adoption time, and here the exit cost of the default is one constant |
Consequences, including the negative ones
- Positive. §6.1’s streaming store exists for the first time, with ADR-060’s central property enforced by a type. The HKDF clause has two independent implementations that a test proves agree, which is a stronger position than the single implementation it had.
- Positive.
Q-098gives the 512KB chunk size one identifier instead of two copies of a literal on either side of the CDN. - Negative. A second implementation of §6.1’s derivation now exists and can drift.
wasm-decoder.test.ts › the two decoders agree byte for byteis the only thing stopping it, and that test needs the.wasmartefact built — apnpm teston a machine without the Rust toolchain fails rather than skipping, which is deliberate (a skip reports green for a module nobody built) and is friction on a first checkout. - Negative.
Q-099andQ-100are Node figures presented as evidence for a browser decision. The scope is written into the registry rows, and the decision is constructed to survive both being wrong by a factor of three — but a reader who quotes the ratio without the scope will be over-claiming, and that is a real risk this record can only mitigate by saying so. - Negative. The
+simd128result is positive and is being overruled by a larger number. A reader who takes “SIMD did not help” from this record will draw the wrong conclusion about the next wasm workload: the flag stays on, it earns its 5.3%, and the artefact is 22% smaller for it. - Negative, and the honest one. The proposal’s actual goal — moving §6.1’s pipeline cost off the critical path — is not addressed by this decision. The place wasm SIMD genuinely pays in that pipeline is the Basis transcode, which this PoC did not touch. Recording the cipher result as “SIMD does not help asset streaming” would be the wrong generalisation.
Enforcement (ADR-045)
decoder.test.ts › records the shipped preference as falsepinsWASM_DECODER_PREFERRED, and the sibling test exercises thepreferWasm: truearm so the constant is a flip and not a dead branch.wasm-decoder.test.tsruns both decoders through one shared suite (aChunkDecoderthat diverges fails), includingdoes not mutate the caller's key material— the asymmetry this suite found on its first run.chunk-store.test.ts › stores the tag and the ciphertext and nothing elseasserts ADR-060’s property against the bytes on disk; theSealedChunkbrand is the structural half.core/descent-asset-decoder/tests/wasm_abi.rs › the_wasm_artefact_imports_nothingandno_unsafe_blocks_anywherehold the artefact’s shape properties.tools/bench-asset-decoder.mjsandexamples/simd_census.rsare the two commands that re-derive this record’s numbers. Neither runs in CI: they are measurements, not gates, and a timing gate on shared runners is a flaky test by construction.
ADR-154 — The SignalR hub protocol is MessagePack; the JSON envelope was base64-encoding every FlatBuffers payload
Status. Accepted
Date. 2026-08-11
Supersedes / Amends / Depends-on. Depends-on 092 (per-viewer targeted delivery, which is
what multiplies envelope overhead by the viewer count); interacts with 029, 032, 150.
Introduces. Q-101 (SIGNALR_MAX_RECEIVE_MESSAGE_BYTES, Derived).
Rights-holders. None. The envelope carries the same bytes in a different frame.
Context
The proposal was to “replace JSON serialization for SignalR with Rust FFI + FlatBuffers”. Checking it moved the answer twice.
The payloads are already FlatBuffers. Every RoomHub method takes or returns byte[];
ADR-032 makes the .fbs set the single wire source and network/wire/codec.ts already decodes
them. There was no JSON payload to replace.
The JSON was one layer up and nothing said so. AddSignalR() with no protocol configured
registers the JSON hub protocol, which has no binary type and therefore base64-encodes every
byte[] argument. That is a 33% inflation on the payload plus an encode server-side and a decode
client-side, per message, per viewer, at Q-001’s 20 Hz.
The tree contradicted itself about it. signalr-transport.ts asserted “SignalR’s JSON
protocol binds it to the hub’s Guid parameter” at one line and “SignalR’s MessagePack
protocol surfaces that as a Uint8Array” thirty lines later. Its header explained that
MessagePack framing was “deliberately not wired up yet” because the schema generation step
“does not exist in this repository yet” — a reason that stopped being true when
tools/generate-protocol-ts.mjs landed, while the note did not. P6 inside a single file. The
package reference to Microsoft.AspNetCore.SignalR.Protocols.MessagePack had been present and
unused in Descent.Vtt.Server.csproj.
The measurement
dotnet run tools/bench-hub-envelope.cs, payloads shaped 68 + 40n (the registry’s own measured
Full Snapshot shape):
- MessagePack’s envelope overhead is a constant ~20 bytes at every size. JSON’s is proportional, converging on 4/3 — base64, exactly. A protocol whose overhead scales with the thing it wraps gets worse as the product succeeds.
- 50 viewers × 100 entities, one tick: 273,600 B (JSON) against 204,400 B (MessagePack). 25.3% of the delivery path; 1.35 MiB/s per room at 20 Hz.
- The epic’s own question came back zero. 500 writes into a reused buffer:
0 bytes allocated by either protocol. ASP.NET Core 10’s
JsonHubProtocolwrites base64 throughUtf8JsonWriterwith no intermediate string. So the GC-pressure motivation the proposal rested on does not exist, and this decision rests on bytes instead.
The defect this found — Q-101
Q-056 bounds the payload at 32,768 bytes. SignalR’s MaximumReceiveMessageSize bounds the
encoded message. They are different quantities, SignalR’s default is also 32,768, and the two
read as one control.
A payload at exactly Q-056, inbound as SubmitOccluders(byte[]), encodes to 43,747 bytes
under JSON and 32,796 under MessagePack — both over the 32,768 default. A Q-056-sized payload
has therefore never been admissible on this hub; the effective inbound payload ceiling was about
24.5 KB, which nobody chose and nothing recorded.
Decision
- Both hub protocols are registered, MessagePack first, through
SignalRRegistration.AddDescentSignalR— a named seam, because a decision made inline in a startup file is one no test can reach. - The client asks for MessagePack. The server half alone changes nothing: a server offers
and a client negotiates, so
AddMessagePackProtocol()by itself would have left every session on JSON with a passing test asserting the offer. - JSON stays registered. A client negotiates its protocol; removing JSON would refuse every browser tab left open across a deploy, which a player experiences as the application breaking.
Q-101 = Q-056 + 28, MessagePack’s envelope constant and deliberately not JSON’s 10,979.descent-net-ffiis refused — see below.check-network-boundary.mjsrule 1 is widened to@microsoft/signalr*.
Alternatives considered and why rejected
| Alternative | Why not |
|---|---|
descent-net-ffi — build the FlatBuffers in Rust behind P/Invoke | The source data is managed RoomGrain state, which a Rust builder cannot read: every snapshot would marshal entity arrays into unmanaged memory, build there, and copy back — two copies added to a path whose entire performance claim is the absence of one. It would also be a second codegen target for the .fbs set that §5a names as deliberately not replaceable, and it puts flatc’s six-mirror pin in a third place, which is ADR-150’s argument unchanged. Refused on structure, and no head-to-head timing was taken — stated plainly rather than implying one was |
| Drop the JSON protocol entirely | One line cheaper and it disconnects every tab open across a deploy. The compatibility is worth one registration |
Set Q-101 from JSON’s envelope (43,747) | Inflates every connection’s receive buffer by a third to admit a maximal payload from an unmigrated client — a case that has never worked, since it was already over the default. Paying for it now would be fixing a bug nobody has ever hit at a cost every connection pays |
Leave MaximumReceiveMessageSize unset | The status quo, and the status quo is the defect: Q-056 unenforceable inbound at a ceiling nobody chose, held in place by a framework default that a version bump could move silently |
permessage-deflate instead | Would change every figure above and costs CPU on the delivery path. A separate question, not an alternative to fixing the envelope — the two compose |
Consequences, including the negative ones
- Positive. 25.3% off the snapshot delivery path, and the envelope overhead stops scaling
with payload size.
Q-101closes a bound that was accidental. - Positive. The stale “not wired up yet” note is gone, the self-contradiction in
signalr-transport.tsis resolved, and the seam has a test. - Negative, and it was nearly worse. This record’s first draft said no end-to-end assertion
existed and named
LiveTypeScriptClientTestsas the suite that should be written. That suite exists, it drives the realsignalr-transport.tsthroughtools/live-hub-driver.mjsagainst a real hub, and it went red the moment the client asked for MessagePack — because both integration hosts hadAddSignalR()copied by hand fromProgram.cs. A negotiation failure presented as{"event":"error","message":"transport-error"}, which is the shape a reader would attribute to the network.RoomHubHost’s own comment said a host that diverged here “would be asserting a hub nobody deploys”; the divergence then happened. Both now callAddDescentSignalR, which is what the seam was extracted for. The residual negative is real: nothing asserts which protocol was negotiated — only that a session works — so a future regression to JSON on both sides would pass. - Negative. A legacy JSON client sending a near-
Q-056payload is now refused at the transport by an explicit bound rather than by a framework default. The outcome is identical; what changes is that it is a decision, asserted in both directions. - Negative. MessagePack is a new runtime dependency in the client bundle (worker chunk only
—
check-optimize-deps.mjsandcheck-bundle-absence.mjsboth hold). Its exit cost is one builder call on each side, which is the same shape as theTransportseam and is recorded here rather than left to be worked out later (P8).
Enforcement (ADR-045)
HubProtocolEnvelopeTests.TheSiloRegistersTheMessagePackHubProtocolandJsonStaysRegisteredSoAnOlderClientCanStillNegotiate— the registration, both arms.TheJsonEnvelopeBase64sABinaryPayloadandTheMessagePackEnvelopeCarriesTheBytesVerbatim— the finding itself, asserted rather than described.TheMessagePackEnvelopeIsSmallerAtEveryRealisticSnapshotSizeandFiftyViewersOfOneTickCostSubstantiallyFewerBytesUnderMessagePack— inequalities, never figures. A size constant in a test fails on a framework bump for a reason that is not a defect.NeitherEnvelopeAllocatesOnTheSteadyStateWritePath— pins zero in both arms, so a future regression that allocates per message on the delivery path is caught.TheInboundLimitAdmitsAQ056PayloadUnderMessagePackAndRefusesItUnderJson—Q-101, both arms.tools/check-network-boundary.mjsrule 1 confines@microsoft/signalr*to one module.LiveTypeScriptClientTestsis the end-to-end gate, and it is the one that fired. It runs the realsignalr-transport.tsunder Node against a real hub; a client and server that fail to negotiate cannot complete a session, so a one-sided migration is red. Both integration hosts callAddDescentSignalRrather than copying it — that copy is what broke, and removing it is part of this decision rather than incidental cleanup.tools/bench-hub-envelope.csre-derives every figure above. Not in CI: it is a measurement, not a gate.
ADR-155 — Volumetric occlusion, and why its budget is a work count rather than a deadline
Status. Accepted
Date. 2026-08-11
Supersedes / Amends / Depends-on. Depends-on 017 (one implementation, bit-exact across two
hosts — the property that refuses the millisecond budget); Depends-on 034 (the disclosure
asymmetry the new query preserves unchanged); interacts with 033, 056, 091.
Introduces. Q-102 (VOLUME_VISIT_BUDGET, Pending).
Rights-holders. None. Panel heights are map geometry, retained exactly as walls already are.
Context
Descent.Geometry answered visibility in two dimensions. Every occluder was a segment, so a
crate you could see over and a wall you could not were the same object. The proposal was a
volumetric core with a 300 ms budget and a fallback to 2D projection when exceeded.
The core was built. The budget was refused and replaced, and that is the decision worth having a record for.
Decision
- A
Panel— a plan-view segment extruded between two closed heights — is the 3D occluder, and a triangle mesh is not. The restriction is what keeps the arithmetic exact: a vertical plane contains the world’s vertical axis, so the crossing is decided by the sameorient2dthe oracle already verifies and the height test is one comparison of twoi128products. - The budget is
Q-102, counted in node visits, and a millisecond deadline is refused. ADR-017 requires the native and WebAssembly builds to agree bit for bit. A wall-clock reading is not a function of the inputs: two hosts cross a threshold at different moments and return different answers — 3D on one, projected 2D on the other — about whether one player can see another.Q-058’sPATH_EXPANSION_BUDGETis the precedent, not an analogy. - The host keeps its 300 ms, outside the 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.
- The 2D projection fallback is permitted because of the direction of its error. A projected panel blocks a superset, so visibility is a subset: it can hide something visible and can never reveal something hidden. §5.4 makes visibility a security property, and that asymmetry is the entire justification.
Bvhis not retired. The two cores coexist and a test asserts they agree on a scene with no height in it.
Alternatives considered and why rejected
| Alternative | Why not |
|---|---|
| The 300 ms wall-clock budget as specified | It makes the answer depend on a clock, which breaks ADR-017’s bit-exactness — the property the crate exists to provide. Not a performance objection: the same query on the same scene would answer differently on two hosts |
| An octree | It subdivides space, which pays when occupancy is uniform and sparse. This workload is walls clustered on a floor plan with empty air above, so an octree spends its depth splitting the air and puts every wall in a handful of cells. A BVH subdivides the set, which is what is clustered |
| A general triangle mesh occluder | Needs a plane equation and a barycentric coordinate, neither of which survives fixed point without rounding — and predicates.rs records why rounding before a sign is fatal. It is also a capability with no author: maps are walls on a floor plan |
Extending Segment with heights instead of a new type | Would put a height on every 2D query in the crate, including fow.rs and path.rs, which have no use for one. A new type leaves the 2D core provably unchanged, which is what full_height_panels_agree_with_the_two_dimensional_core asserts |
Returning Blocked on budget exhaustion | Fails safe in the disclosure direction and is still wrong: it makes a timer the author of a visibility decision, and a room under load would silently blind its players. BudgetExhausted is a third outcome so the caller chooses |
Returning Clear on budget exhaustion | Would let a load spike disclose a hidden token. Not seriously considered; recorded because the symmetry with the row above is the reason BudgetExhausted is a distinct outcome rather than either answer |
Consequences, including the negative ones
- Positive. Sight lines over crates and under balconies are expressible for the first time, in the same exact arithmetic and with the same disclosure asymmetry as the 2D core.
- Positive. The budget’s unit change makes a deterministic performance assertion possible.
the_z_axis_prunes_and_the_budget_proves_itstates “this query pruned” as a fact true on every host — the only instrument found that can assert an acceleration property without becoming a flaky test. - Negative, and the largest. No host can call any of this.
bindings/c_abiandbindings/wasmexpose the 2D surface only, so the module is complete, tested, and unreachable from both the silo and the browser. - Negative. The oracle has no volumetric arm. The 2D predicates are verified against exact
rational arithmetic (ADR-056);
panel_blocksis verified against the linear scan, which is this crate checking itself. That is a weaker guarantee, and the height test being “simple enough to read” is exactly the standard ADR-056 exists because nobody should trust. - Negative. Two cores now coexist and nothing yet decides which a caller should use.
- Negative. ADR-091’s permeability has no volumetric sibling — a glass balcony is opaque
here — and
fow.rsstill advances 2D chunk masks.
What the mutation sweep changed about this record
Twelve mutations, twelve caught, one surviving by design. Two survived the first sweep and neither was an ordinary test gap:
- A sign-normalising branch in
panel_blockswas dead weight. Replacing its negating arm with a no-op broke nothing, because negating all three operands of amin/maxbracket leaves the predicate unchanged. It was deleted; a reader would have taken it for the mechanism that handled negative denominators, and the mechanism is the bracket. - Dropping the z axis from the BVH’s box test changes no answer, necessarily — an acceleration structure may not. The consequence is that correctness tests are structurally blind to the acceleration, which is why the pruning assertion above had to be written against the budget rather than against an answer.
Enforcement (ADR-045)
tests/volume.rs— 21 assertions.the_hierarchy_agrees_with_the_linear_scanholds the acceleration structure to the oracle it may not disagree with.the_plan_projection_never_reveals_more_than_the_volume— decision 4, as a randomised property rather than a paragraph.the_budget_outcome_is_a_function_of_the_inputs— decision 2’s determinism, andan_ample_budget_answers_and_a_zero_budget_does_notpins both ends so “exhausted” is neither unreachable nor universal.the_z_axis_prunes_and_the_budget_proves_it— the acceleration itself, asserted deterministically.full_height_panels_agree_with_the_two_dimensional_core— decision 5.VisitBudgethas noDefault;VolumeBvh::buildrequires the host’s ceiling. Both are structural, in the sense P3 ranks above a test.#![deny(clippy::arithmetic_side_effects)]remains crate-wide; every exemption involume.rscarries its magnitude argument, which is the disciplinepredicates::orient2destablished.
ADR-156 — WebGPU compute feathers the authoritative mask; it does not compute visibility
Status. Accepted
Date. 2026-08-11
Supersedes / Amends / Depends-on. Depends-on 034 (the client may not produce visibility,
which is what makes a GPU workload on this boundary permissible at all); Depends-on 064 (the
capability injection point and the device.lost fault this is the first consumer of);
interacts with 005, 017, 035, 155.
Introduces. No Q-ID — see the note under Decision 5.
Rights-holders. None. The input is a mask the server already sent.
Context
Two epics proposed moving geometry to WebGPU compute: “migrate high-intensity spatial calculations from the CPU WASM Geometry Worker” and “migrate WASM-CPU raycasting to a WebGPU Compute Shader BVH for 3D soft shadows”.
Both meet an existing rule, and the rule is in the crate’s own header. ADR-017, restated in
Descent.Geometry/src/lib.rs: “There is deliberately no second C# implementation, no second
Rust implementation and no authoritative WGSL implementation — a capability described three
times in three languages cannot be kept in agreement.”
And the migration has no subject. The Geometry Worker computes pathfinding and movement
prediction. It does not raycast, because the client is forbidden from producing visibility by
four mechanisms that agree: §9.2’s scope, the wire’s unconditional DISCLOSURE_PARTIAL,
render/mask.ts’s type with “no way to construct it from geometry”, and render/fog.ts’s
“there is no input from which it could”. A* is the actual workload and is the wrong shape for
a compute shader — the GPU formulations are different algorithms, which is a second
implementation by another route.
What is legitimate on this boundary was already scoped by epic_presentation_smooth_fog.md
§2B: feathering the authoritative mask inward, “zero disclosure risk”, as a distance
transform. That is what was built.
Decision
core/descent-fog-computeowns one WGSL compute shader and one CPU reference, andtests/parity.rsexecutes the shader on every adapter the machine reports — five here, including the software one — asserting byte-for-byte agreement.- The invariant is an early return, not a property.
if (mask[i] == 0u) { alpha[i] = 0u; return; }runs before anything else in both implementations, soepic_presentation_smooth_fog.md’s “feathering may only work inward” is structural (P3) rather than preserved by care. - Chebyshev, not Euclidean. Integer and exact on every backend;
sqrt’s last bit may differ between drivers, which would make a player’s fog edge a function of their GPU vendor against that document’s own cross-device parity requirement. - The fallback is to stop feathering, not to a CPU path. Measured: hardware 0.30 ms per
Q-011chunk, CPU reference 7.55 ms (45% of a 60 Hz frame, native — wasm is worse), and a software adapter 48.70 ms, which is 6.4x slower than the CPU.radius = 0is the hard-edged fog the client draws today, and the revealed set is identical either way because feathering is inward-only. - No
Q-IDis introduced, and the radius is deliberately not one.DEFAULT_FEATHER_RADIUS_CELLSbounds nothing: a larger radius is dimmer and never brighter (asserted), so the knob cannot become a disclosure control. Rule 4 of the registry asks for numbers that constrain. - A lost device is a third state.
featherPlananswersready/absent/lostseparately, because Guardrail 1’s Recovering state is a different thing to be in and a different sentence to be told. §14.6’sdevice.lostfault gets its first consumer. - The invariant is re-checked at run time, not only in CI.
isInwardOnlyships in the frontend module because the producer is a driver, and an invariant whose only enforcement is a CI run on somebody else’s GPU is not enforced on the one that matters.
Alternatives considered and why rejected
| Alternative | Why not |
|---|---|
| A WGSL LOS/raycast core, as proposed | ADR-017 names and excludes it, and §9.2 removes the subject: the client does not compute visibility and may not be given the occluder geometry it would need |
| A GPU pathfinder (wavefront / flow field) | A different algorithm, so a second implementation by another route, needing its own parity corpus against the exact oracle (ADR-056) for a workload nobody has measured as a bottleneck |
| A TypeScript CPU feathering fallback | A third implementation of an algorithm that already has two bound by a parity corpus — and at 7.55 ms native for one chunk it does not fit a frame anyway |
| Falling back to a software WebGPU adapter | Measured at 48.70 ms per chunk, 6.4x slower than the CPU reference. Strictly worse than not using a GPU. And a browser cannot make this choice at all — requestAdapter() may return a software adapter with no way to tell |
| Euclidean distance for a rounder falloff | sqrt is the one operation whose last bit drivers may differ on. The visible gain is a rounder corner; the cost is a fog edge that depends on the player’s GPU vendor |
Bit-packing the mask to u8 for upload | WGSL has no 8-bit scalar without an extension, so the shader would unpack and the CPU reference would have to implement the identical packing — for a 64 KiB grid uploaded at 5 Hz against a 0.30 ms dispatch. Optimising a term that is not the cost |
Consequences, including the negative ones
- Positive. A WGSL shader in this repository is now executed by CI-runnable tests on every adapter present, rather than first executed on a player’s GPU. Two static parses also pin the workgroup size and the binding set, which WGSL cannot report at run time.
- Positive.
epic_presentation_smooth_fog.mdPhase 1’s primary mechanism exists and its stated invariant is enforced structurally in both implementations and re-checked at run time. - Negative, and the largest. Nothing consumes any of it. There is no WebGPU path in the
Render Worker (
WEBGPU_BACKEND_BUILTis stillfalse), sofeatherPlanhas no caller and the WGSL is not copied intosrc/— deliberately, since a generated file nothing imports is the shape this corpus criticises elsewhere. The copy step arrives with the consumer. - Negative. The device-lost transition is not implemented.
featherPlananswers correctly for a lost device; nothing listens toGPUDevice.lost, and nothing rebuilds. Guardrail 7’swebgpu-device-lossrow stays blank and its blank reason stays accurate. - Negative. The measured adapter ladder is enforceable in the Rust host and not in the client, because a browser cannot see its adapter’s tier. A player on a software adapter gets 48 ms of feathering per chunk and this decision cannot prevent it.
- Negative. The figures are wgpu on Vulkan/DX12, not Chromium’s Dawn, and no
Q-IDclaims them. - Negative. It is 2D. A volumetric soft shadow needs the Disclosed Static Geometry Cache
§2C names as a prerequisite, and ADR-155’s
VolumeBvhis not consulted.
Enforcement (ADR-045)
tests/parity.rs— 10 assertions, includingthe_shader_agrees_with_the_cpu_reference_on_every_adapterandthe_shader_only_feathers_inward_on_every_adapter. Fails rather than skips when no adapter is found, for the reasonrust-geometry-guidelines.md§7 gives.the_declared_workgroup_size_matches_the_shaderandthe_shader_declares_exactly_the_three_bindings_the_host_binds— static parses of the shader source, closing the two mismatches WGSL cannot report at run time.a_larger_radius_never_brightens_a_cell— decision 5’s argument that the radius cannot become a disclosure control.feather.test.ts— 15 assertions overfeatherPlan, including that a lost device and an absent capability produce different sentences, and that a fractional radius is refused rather than rounded (rounding is the one difference the parity corpus cannot express).examples/tier_cost.rsre-derives decision 4’s figures. Not in CI: a timing gate on a shared runner is a flaky test by construction.
ADR-157 — RNNoise in the AudioWorklet needs no SharedArrayBuffer, and the device swap is a comparison rather than an event
Status. Accepted
Date. 2026-08-11
Supersedes / Amends / Depends-on. Depends-on 096 (the finding that the absence of
cross-origin isolation gates no capability, which is what refuses the SAB gate);
Depends-on 064 (the capability injection point the telemetry channel is resolved from);
interacts with 081, 108.
Introduces. No Q-ID — see below.
Rights-holders. None new, and the qualifier is load-bearing: ADR-081 already governs
voice, and denoising happens before capture reaches any transport. No audio is retained by
this decision, and the model is a fixed set of trained weights that learns nothing across
sessions.
Context
Two epics: a Rust WASM AudioWorklet denoiser “using tract or RNNoise” with
SharedArrayBuffer and a native-echoCancellation fallback; and device hot-swapping through
ondevicechange + replaceTrack + setSinkId.
§9.6.1 line 841 already scopes the first: “Heavy Digital Signal Processing […] must strictly
execute within an AudioWorkletGlobalScope.”
Decision
nnnoiseless— the Rust port of Xiph’s RNNoise with the published weights — onwasm32, zero imports, 487 KB, behind the buffers-and-offsets ABIdescent-asset-decoderuses.tractwas not evaluated: it is a runtime that would need a model chosen, converted and licensed, where this is the model. The swap point isDenoiser::process_frameand the exit cost is recorded inCargo.toml(P8).SharedArrayBufferis not required and the denoiser is not gated on it. AnAudioWorkletProcessoris handed its buffers inside the audio thread’s own global scope and the wasm module lives there too, so audio never crosses a boundary. Gating on isolation would switch the feature off for every deployment without COOP/COEP, against ADR-096’s own finding.- Isolation decides the telemetry channel only — how 375 voice-activity readings a second
reach the main thread — which is
presentationChannel’s trade indegradation.ts, in a different subsystem.denoisePlananswers two questions and keeps them apart. - The native fallback is kept and retargeted to the three conditions that actually prevent
the worklet from running: no
AudioWorklet, no payload, or a sample rate the model was not trained at. - The worklet plan switches the browser’s own suppression off. Two adaptive gates in series pump: the browser’s runs first on the raw capture, so RNNoise adapts to a floor another adaptive system is modulating.
reconcileDevicesis idempotent and preference-ordered.devicechangefires more than once per physical event and describes nothing, so the handler compares two device lists; and a preference is a list, so unplugging a headset and plugging it back in returns the player to it.- The two halves of a swap fail independently, and the outcome names which failed.
Alternatives considered and why rejected
| Alternative | Why not |
|---|---|
Gate the denoiser on SharedArrayBuffer, as proposed | Nothing to share: audio never leaves the AudioWorkletGlobalScope. It would disable the feature on every non-isolated deployment for no property gained, against ADR-096 |
tract with an ONNX model | A runtime rather than a model. It needs a model chosen, converted, licensed and validated — where nnnoiseless is RNNoise with the published weights. The swap point exists if a future model arrives as ONNX |
| Keep the browser’s suppression on as well | Two adaptive gates in series pump. Measured as an argument rather than a measurement — this one is a property of cascaded adaptive systems, and the honest place to check it is a listening test that does not exist |
Stop-and-start the track instead of replaceTrack | Triggers renegotiation, which against an SFU is a round trip and an audible gap. replaceTrack is the whole reason a swap can be seamless |
| Store the device preference as a single id | A player who unplugs a headset and plugs it back in would stay on the laptop microphone. One line of data modelling buys the behaviour that makes this feel finished |
| Roll back a capture swap when the sink swap fails | A player who can be heard but cannot hear is in a different situation from one who can do neither. Undoing the half that worked is strictly worse |
Consequences, including the negative ones
- Positive. RNNoise runs on
wasm32with zero imports, so one artefact is instantiable by a browser worklet, a Node harness and a Rust host with no glue. - Positive. The 128-to-480 reframing is in one place with its cost asserted (exactly one frame, 10 ms). A processor that skipped it would denoise nothing and read as a bad model.
- Negative, and the largest. Nothing has run on an audio thread. There is no
AudioWorkletProcessor, no loader, and no voice subsystem for either module to attach to — §7.1’s LiveKit SFU does not exist. Both modules are proven on the host target and in Node. - Negative, and the one that could stop this shipping. The real-time budget is
unmeasured. 2.67 ms per quantum at 48 kHz, and nothing has measured RNNoise’s
wasm32frame cost against it.examples/frame_cost.rsis named in the toolchain pin and does not exist.P7: this cannot be advanced by writing. - Negative. Turning
echoCancellation: falseremoves a capability. RNNoise suppresses noise; it does not cancel echo, which needs the far-end reference. On a device without hardware AEC the worklet plan is a regression, which is why the plan is a value a caller may override rather than a constant. - Negative.
setSinkIdis unsupported in Firefox without a flag and absent on iOS Safari, so the sink half will always reportfailedthere. §14.6’s record has no row for it, and adding one obliges a Guardrail 7 row whose title must be the whitepaper’s verbatim — a corpus change this decision did not make. - Negative.
+simd128is set on the wasm build and unmeasured. Unlike AES it has a plausible target here (FFT and dense layers are float lanes), and plausibly is not a measurement.
On introducing no Q-ID
Three numbers appear and none constrains anything the system does. RNNoise’s 480-sample frame and 48 kHz rate are properties of a trained model, not choices — registering them would imply they could be set. The 2.67 ms quantum budget is fixed by the Web Audio specification. The number that will need a row is the measured frame cost against that budget, and it does not exist yet; inventing a row for it now would be a Pending entry with no owner.
Enforcement (ADR-045)
tests/denoise.rs— 6 assertions.denoising_attenuates_broadband_noise(13.8 dB) anddenoising_preserves_a_loud_tone(107.2% retained) are the pair that replaced a waveform SNR; the replaced measurement and why it was wrong are kept in that test’s doc comment.the_reframer_adds_exactly_one_frame_of_delay— the latency claim, against an identity transform so the FFT is not in the way.a_short_quantum_is_refused— a host bug that would otherwise slide the reframing alignment permanently and surface minutes later as intermittent roughness.audio.test.ts— 21 assertions.runs the worklet without cross-origin isolationis decision 2;chooses the telemetry channel from isolation, and only thatis decision 3;does nothing when the devices in use are still the right onesis decision 6’s idempotence;stops the new track when replaceTrack rejectsandreports which stage failedare decision 7.- The wasm artefact’s import section is checked to be empty rather than inferred from a successful instantiation.
ADR-158 — The 2D bake ladder’s bottom rung emits an image, because the bake is a registration gate
Status. Accepted
Date. 2026-08-11
Supersedes / Amends / Depends-on. Depends-on 005 (the mandatory 2D bake and its
registration-time refusal, which is what makes the placeholder rung necessary); interacts with
068, 156.
Introduces. No Q-ID.
Rights-holders. None. A bake is derived from a bundle the pipeline already holds.
Context
ADR-005 makes a top-down 2D bake a registration gate: a bundle without one is refused
Profile C availability and renders as a “labelled footprint placeholder”. Nothing produced
one. The proposal was a headless wgpu pipeline with a hardware -> software -> placeholder
ladder.
Decision
tools/descent-asset-bakerrenders headlessly - no window, no surface, no swapchain - so the bake happens at registration time rather than being asked of an author.- The placeholder rung emits a real PNG, not an error, because a failing rung would make the registration gate depend on the build agent’s graphics stack. The pipeline’s fallback and the Profile C player’s degraded view are the same artefact, which is what makes the rung sound rather than a fudge.
Bake::tiertravels with the image. A placeholder accepted as a render is the failure this rung exists to make visible.- A device that was found and then failed does NOT fall to the placeholder. No adapter is an environment fact; a driver that accepted a device request and then failed is a fault an operator must see. Same distinction ADR-156 draws between absent and lost, elsewhere.
AdapterProbeis injected, so each rung is reachable from a test rather than by uninstalling a driver.
Alternatives considered and why rejected
| Alternative | Why not |
|---|---|
| Fail the build when no adapter is present | Makes the ADR-005 gate a property of the build agent rather than of the asset. An author would be told their bundle was wrong because a container had no GPU |
| Emit a flat coloured square as the placeholder | Indistinguishable from a real bake at a glance, on a map made of them. Section 2.2 says labelled footprint; the hatch is the label this layer can draw |
| Fall to the placeholder when a device is lost | Turns one broken build agent into a catalogue of footprint tiles nobody noticed |
| Assert byte-for-byte equality across adapters | Rasterisation permits backends to disagree on pixels exactly on a primitive edge. It would assert a property WebGPU does not provide, and would fail for a reason that is not a defect |
| Choose only 256-aligned tile sizes and skip the unpad | It would work, and it would hide the bug rather than fix it. The test uses 50 px precisely because 64 px would pass with the defect present |
Consequences, including the negative ones
- Positive. All three rungs execute in a test, and every adapter on this machine produces the same silhouette with no channel differing by more than 2/255.
- Positive. The 256-byte readback stride - the classic offscreen defect, which produces a sheared image that still decodes as a valid PNG - has a test that uses a size the bug would survive.
- Negative, and the largest by a wide margin. There is no mesh loader, so the pipeline
draws the footprint volume rather than the asset. The silhouette is right and the interior is
not, and no real bundle’s tile can be produced until
render_top_down’s seam has a glTF loader behind it. - Negative. Nothing calls it. No worker, no queue, no manifest write, no registration gate
consuming
Bake::tier. ADR-005’s obligation is still enforced by nothing. - Negative. The
Softwarerung’s cost here is unmeasured; ADR-156’s 6.4x figure is for a compute dispatch, not this render, and quoting it across would be the mistake that ADR’s own note warns about. - Negative. Section 2.2 asks for “sprite/tile + footprint”; this emits the sprite and takes the footprint as an input, which is backwards from a pipeline that would derive both from the mesh.
- Negative. lavapipe is named and untested - the software rung ran as WARP on Windows, and a Linux CI container is where this would actually run.
Enforcement (ADR-045)
tests/ladder.rs- 6 assertions.no_adapter_produces_a_labelled_placeholder_rather_than_a_failureis decision 2, and it checks the hatch as well as the footprint.every_adapter_bakes_the_same_tile- silhouette equality and a 2/255 channel bound across every adapter present. Fails rather than skips with no adapter.a_size_whose_row_is_not_256_aligned_reads_back_unsheared- the stride, at 50 px.an_invalid_request_is_refused_rather_than_clamped- a zero-pixel bake would satisfy the ADR-005 gate with a file containing nothing, which turns the gate into a formality.
ADR-159 — The fuzzer’s budget is the control; AFL++ is the discovery, and it is not runnable here
Status. Accepted
Date. 2026-08-11
Supersedes / Amends / Depends-on. Depends-on 140 (the sandbox’s proposal set, which this
engine must configure identically or it validates a sandbox nobody runs); Depends-on 147 (the
zero-import cartridge shape, enforced here by an empty linker); interacts with 100, 155.
Introduces. No Q-ID — see below.
Rights-holders. None. A cartridge under test is an upload the Marketplace already holds.
Context
The proposal was an AFL++ fuzzing node bombing untrusted community cartridges under a 10 MB heap and 20 ms timeout. A fuzzer has two halves and only one is a security control: discovery (how inputs are found) is a search heuristic, and enforcement (what stops a bad one) is what runs in production.
Decision
- Enforcement is built and exhaustively tested; discovery is a deterministic xorshift walk biased toward the corner values an ABI breaks on, and the audit records what that costs.
- Two budgets, and they are not interchangeable. Fuel is deterministic and is the
production budget, because a creator’s refusal must be reproducible. The epoch deadline is a
backstop, because fuel does not bound time — a blocking host call or an engine
pathology burns none.
FuzzTargetarms both and reports which fired. - ADR-155 refused a wall-clock budget and this accepts one, for a stated reason. That ruling binds a deterministic core whose answers must match across two hosts. A sandbox’s job is to stop things, and a backstop that fires non-deterministically still stops them.
- The engine configures ADR-140’s proposal set exactly, not a convenient superset: a fuzzer running wider than production finds inputs that cannot occur and misses the ones that can.
- A fresh
Storeper invocation. A store carries the guest’s whole linear memory, so reusing one lets input n see input n-1’s heap — and a campaign whose cases are not independent reports findings that cannot be reproduced from the input that caused them. - The fuel figure is not registered as a quantity. It cannot be derived from a millisecond
budget without a measured instructions-per-millisecond rate that does not exist, and a number
nobody measured should not acquire a
Q-ID’s authority.
Alternatives considered and why rejected
| Alternative | Why not |
|---|---|
| AFL++, as proposed | Not runnable here. Its fork-server depends on fork() and there is no supported Windows build; cargo-fuzz/libFuzzer needs clang and a supported platform. And on Linux it would instrument the host, so a campaign would be steered by coverage of wasmtime rather than of the cartridge — it would find engine bugs, which is worthwhile and not the stated goal |
| A wall-clock budget alone | Fuel is what makes a refusal reproducible. A creator told “your cartridge was too slow on our machine that afternoon” has nothing to act on |
| Fuel alone | Fuel does not bound time. A blocking host call burns none, and the DeadlineWithFuelRemaining outcome exists precisely to make that case visible rather than invisible |
Reuse one Store across cases for speed | Cross-contamination: a finding attributed to input n would in fact need inputs 1..n. Reproducibility is the one property a fuzzer cannot trade |
| A committed binary corpus of hostile modules | Nobody can review it. Every fixture here is four lines of WAT a reviewer can read |
| Have the fuzzer decide publication | A tool that both finds problems and adjudicates them is one whose false positives are unappealable. It classifies; Q-046’s review path decides |
Consequences, including the negative ones
- Positive. Every classic attack — infinite loop, memory bomb, unbounded recursion, out-of-bounds store, smuggled import, disabled proposal — is contained and asserted, against the engine configuration the silo actually deploys.
- Positive. Outcomes are six classes rather than pass/fail, because a reviewer’s next action differs for each; collapsing them would make a merely slow cartridge indistinguishable from one attacking the engine.
- Negative. Discovery depth. A xorshift walk finds shallow problems quickly and deep ones never. What the absence does not cost is the control: the budget is enforced on every input whether or not a mutator produces an interesting one.
- Negative. No real cartridge was fuzzed. Every fixture is hand-written WAT; nothing has run against the ABI a creator writes against.
- Negative. The generator bombs the ABI boundary (
descent_invoke(ptr, len)) and never the payload. ADR-145’s envelope is JSON in linear memory and a structure-aware generator over it does not exist. - Negative. Nothing runs it — no CI job, no review-path hook, no reporting.
- Negative. No differential check against the C# host.
WasmSandboxEngineand this crate configure the same engine independently, and a drift between them would mean the fuzzer validates a sandbox the silo does not run.
Two defects the corpus found in this crate, recorded because their shapes recur
- The deadline ticker made every invocation cost the full deadline. It slept and was then joined, so a 400-case campaign at 200 ms took eighty seconds — the suite hung rather than failed, which is how it was noticed. It now waits on a channel. It is still joined, and that is what forces the channel: a detached ticker bumps the epoch during the next invocation, attributing one case’s timer to another case’s input.
- The error mapping dropped wasmtime’s cause chain.
anyhow’sDisplayis the outermost message only —failed to compile: wasm[0]::function[0]— which names neither the proposal nor anything a creator could act on; the chain holds “SIMD support is not enabled”. The assertion that the refusal names the proposal is what caught it.
Enforcement (ADR-045)
tests/hostile.rs— 9 assertions, one per attack, plus a 400-case campaign asserting every outcome is contained, that at least two outcome classes are reached (or the arguments are not reaching the branches), and that the run reproduces from its seed.a_well_behaved_cartridge_completesis the control: without it, a suite where everything is refused proves only that nothing runs.a_module_with_an_import_cannot_instantiateholds ADR-147’s rule through an empty linker rather than a check that can be forgotten.each_invocation_gets_a_fresh_linear_memoryis decision 5.
ADR-160 — A hidden roll’s doubt is temporal, so commit-reveal answers it and a ZKP does not
Status. Accepted
Date. 2026-08-11
Supersedes / Amends / Depends-on. Depends-on 016 (the authority tier that owns the dice,
which is what removes the client-held secret a ZKP would prove something about); interacts with
041, 094, 137.
Introduces. No Q-ID.
Rights-holders. None. A commitment and a seed are values the room already produced.
Context
The proposal was a Plonky3 STARK proving a GM’s hidden roll, compiled to the frontend, with HMAC-SHA256 commit-reveal as a fallback if proving exceeded 2000 ms.
A ZKP lets a prover who holds a secret convince a verifier without revealing it. It is the
right tool when the prover is both the party you distrust and the party doing the computing.
Neither holds: ADR-016 makes RoomGrain authoritative and the dice come from
DESCENT-DRBG-HMACSHA256-CTR-v1 inside the silo, so there is no client-held secret; and players
already accept the server’s word on visibility, validation and every secret in the room, so a
proof about a server assertion proves something to someone who has accepted strictly more.
The doubt a hidden roll creates is temporal: not “is the arithmetic right” but “was the seed chosen after the outcome was known to be convenient?” Commit-reveal answers exactly that.
Decision
- Commit-reveal is the mechanism, not the fallback.
core/descent-roll-commitpublishesHMAC-SHA256(seed, DOMAIN || context)before the roll and the seed after. - The seed is the HMAC key and the context is the message. Committing to the outcome under a public key is a hash of a value with twenty possibilities. The corpus performs that attack against the wrong construction rather than describing it.
- The commitment binds a context — room, sequence, sides, count — serialised fixed-width and big-endian with no separators, so two contexts cannot share a serialisation.
- Rejection sampling, not modulo, and deterministic, so a verifier re-deriving the roll takes the identical path.
hmac+sha2, the DRBG’s own primitives. A second hash beside a published wire contract would be a second cryptographic dependency for one subsystem.
Alternatives considered and why rejected
| Alternative | Why not |
|---|---|
| A Plonky3 STARK, as proposed | Aimed at the wrong party. There is no client-held secret (ADR-016 owns the dice) and players already accept a strictly stronger assertion from the same server. Not refused for performance — see below |
| Refusing it on the grounds that Plonky3 does not build for wasm32 | It does. Probed: p3-* 0.6.3 compiles clean for wasm32-unknown-unknown on stable 1.97.1. Refusing on a wrong reason invites the whole idea to be re-proposed the moment someone checks |
plonky2 instead | plonky2_field requires #![feature(specialization)], nightly-only. Disqualified by the toolchain pin before any argument about fitness |
| Committing to the outcome instead of the seed | Twenty guesses recover a d20. The corpus brute-forces it to make the point non-negotiable |
x % sides | Biased toward low faces whenever sides does not divide 2^32. Invisible in a session, wrong over a campaign |
| A commitment with no context | One commitment could be reused for whichever later roll suited, or a favourable seed replayed from another room |
Consequences, including the negative ones
- Positive. The player-facing property — the number was fixed before it was convenient — is now verifiable by a player’s own client from two published values.
- Positive. The Plonky3 build question is answered rather than assumed, so the refusal cannot be overturned by a ten-minute check.
- Negative. Nothing calls it. No hidden-roll command, no commitment on the wire, no
reveal message, no client verifier, and no
wasm32artefact. The mechanism is complete; the feature is not. - Negative. The reveal’s timing is unmodelled, and it is where the real design question is. A GM who never reveals is indistinguishable from one whose roll was honest and private. Reveal at session end, on demand, or never — each means something different to a player, and nothing here decides it.
- Negative. It does not stop a server choosing among many seeds before committing. Commit-reveal binds a server to the seed it chose; a player-supplied contribution to the seed would close that, and is the obvious next question with an obvious answer that is not built.
- Negative. The seed’s provenance is out of scope. A low-entropy seed breaks the hiding property this module assumes rather than provides.
Enforcement (ADR-045)
tests/commit.rs— 10 assertions.a_substituted_seed_is_refusedanda_misreported_value_is_refused_with_its_own_reasonare binding, with distinct reasons because the remedies differ.a_commitment_answers_for_exactly_one_context— decision 3, over all four fields.a_commitment_over_the_outcome_would_be_guessable— decision 2, by carrying out the attack.a_d100_is_not_visibly_biasedover 20,000 rolls — decision 4, as a bound rather than a statistical test, because a tight bound would be a flaky one.the_roll_is_deterministic— a verifier must re-derive the same value, rejection sampling included.
ADR-161 — Tantivy is an ADDITIONAL T2 projection, and embedding it makes ADR-043’s watermark per-replica
Status. Accepted
Date. 2026-08-11
Supersedes / Amends / Depends-on. Depends-on 043 (the rebuild watermark, whose single-value
assumption an embedded index breaks); interacts with 002, 016, 111.
Introduces. No Q-ID.
Rights-holders. None new. The projection holds text the event stream already holds, and
ADR-080’s erasure reaches it the same way it reaches the flattened pg_trgm row — which is a
dependency the erasure job would have to be told about, and is listed under Consequences.
Context
The proposal was to replace PostgreSQL JSONB queries with an embedded Tantivy index reached by FFI from the silo, rebuilt from Marten.
“Replace” does not survive contact with §2. T2’s JSONB read models are what a sheet, a vault listing and a timeline read from; a search index finds documents rather than returning them. What Tantivy is, correctly placed, is an additional T2 projection for the queries JSONB answers badly — free-text relevance over notes, handouts and transcripts.
That is not a fourth source of truth. §2’s prohibition is about authority tiers, and a second T2 projection is still T2.
Decision
- An additional T2 projection, not a replacement, inheriting every T2 rule unchanged.
searchreturns ids and a watermark and nothing else. A payload carrying state invites a caller to act on it; an id is resolved through the authoritative path, where a permission decision belongs.SearchHitshas no constructor omittingsource_event_seq, so results without a watermark are unrepresentable. Even an empty result set carries it: “no results at seq 30” and “no results, position unknown” are different answers.- A rebuild deletes first. A rebuild whose result depends on what was already there is not one, and the defect it hides — a document deleted from T1 surviving in the projection — is invisible until someone searches for something that should not be findable.
- An event behind the watermark is refused, not clamped. Clamping leaves the document applied and the watermark wrong, so the index holds data it does not admit to holding.
- The index is in RAM. A persisted projection needs reconciliation with the stream at startup, which is a second recovery path beside the rebuild and the one that fails quietly.
Alternatives considered and why rejected
| Alternative | Why not |
|---|---|
| Replacing the JSONB read models, as proposed | They are read models, not a search index. Replacing them substitutes a thing that finds documents for a thing that returns them |
| Persisting the index to disk | A second recovery path beside the rebuild, and the one that goes wrong quietly when a replica is restored from a snapshot older than its index |
| Returning documents rather than ids | §2 rule 1. A T2 payload carrying state invites a caller to act on it without a permission decision |
Making source_event_seq an optional field or a separate accessor | A caller can forget to ask. Rule 2 exists because a stale answer that looks fresh is the failure mode |
| A shared out-of-process search service | Not evaluated, and it is the alternative that would answer §3’s coherence problem directly. Recorded as an open question rather than dismissed — ADR-002 rejects microservices, and whether a search index is a service or a store is a distinction that decision does not settle |
Consequences, including the negative ones
- Positive. Relevance-ranked free-text search over the corpus, which
pg_trgmdoes not do. - Negative, and the finding this ADR exists for. ADR-002 makes the silo one deployable
scaled horizontally, so an embedded index exists once per replica:
- ADR-043’s watermark becomes per-replica. Two consecutive searches from one player can land on replicas at different positions and return different results with no event having happened. Decision 3 makes that detectable; ADR-043’s own words apply — detection is not protection — and “the watermark” is no longer a single value to compare an edit against.
- A rebuild is N rebuilds, and a replica joining mid-rebuild starts from nothing.
- Memory is per-replica. A shared index is paid once.
- Negative. The FFI half is not built, and it is the larger part. No C ABI, no
[LibraryImport], no marshalling of a string result set, and — the part with real design in it — no lifetime model, because anIndexis a live object with an open writer and grains are virtual actors that come and go. §5 makesDescent.Vtt.Geometry.Interopthe only project permitted[LibraryImport], so a second native dependency either lives in a geometry-scoped project or needs its own — a decision this does not make. - Negative. No Marten integration.
rebuild_fromtakes already-projected documents. The epic’s “rebuild strategy from Marten Event Store” is designed and not built. - Negative. No measurement against PostgreSQL. §7.3’s flattened
pg_trgmsearch text already exists, and the case for adopting this rests on capability rather than on a measurement. Stating that plainly is the alternative to implying one was taken. - Negative. ADR-080’s erasure job purges the flattened
pg_trgmrow “in the same job” as the blob. A second text projection is a second thing that job has to reach, and it does not know about this one.
Enforcement (ADR-045)
tests/projection.rs— 7 assertions, each a T2 rule as a property.the_watermark_is_not_optionalcovers decision 3’s runtime half; the compile-time half is thatsearchcannot return anything else.a_rebuild_removes_documents_that_are_no_longer_in_the_stream— decision 4.an_event_behind_the_watermark_is_refused— decision 5, asserting both that the watermark did not move and that the document was not applied.ids_are_not_tokenised_into_the_searchable_text— aSTRINGfield rather thanTEXT, or a search for one entity finds every entity sharing a hyphenated fragment of its identifier.