Architecture Decision Rulings — R40
Architecture Decision Rulings — R40
Date: 2026-08-12 · Produces: ADR-167 (Phase 0), ADR-168…170 (Phase 1) · Findings:
F-R40-01,F-R40-02Not an audit round, and not one round either. §1–§6 are the Discord ruling that opened R40: a re-examination of one clause of ADR-166, which merged the same day, against the Discord platform’s published surface. §7–§12 are Phase 1, a build round that shipped the CQRS read model, the CDC pipeline and the AI session — and refused two clauses of the brief that commissioned it. They share a round number and nothing else; read §7 first if that is what you came for.
0. What this round is, and the two things it is not
R39 Phase 2 shipped voice-plan.ts, discord-activity.ts and discord-sdk.ts, and ADR-166 ruled
that a Discord Activity moves voice and never identity. This round asked one question that
decision did not: what can an Activity actually do with voice, as opposed to what did we decide
not to do with it?
ADR-166’s voice ruling is not overturned. It is correct and it stays. What changes is its grounding, which turns out to be much stronger than the reasons recorded for it — and a decision resting on weaker grounds than the ones available is a decision a future contributor will re-open, because the recorded reasons read as preferences that could be traded away.
The information-leak argument applied here was not discovered by this round. ADR-091 won it in
R7, for entity sound and leaked illumination, and Studio_Architecture.md §8 restates it. This
round’s contribution is narrower and is the finding: ADR-091 was never applied to player
voice, and three whitepaper bullets plus ADR-012’s record still describe player-voice
spatialisation as a client-side computation over avatar coordinates. Saying this round found the
oracle argument would be taking credit for R7’s work; the corpus has a rule about that (P9).
1. F-R40-01 — the finding
F-R40-01 — Player-voice spatialisation is specified as a client-side computation on avatar
coordinates, and ADR-091’s server-authoritative audibility rule was never applied to it.
Four locations describe the client attenuating and panning a player’s voice from that player’s coordinates:
| Location | What it says |
|---|---|
| Whitepaper §6.1 “WebRTC Spatial Audio & Mesh Texture Linkage” | isolated LiveKit tracks “fed into the Babylon.js 3D Spatial Audio API (PannerNode), enabling perfect, client-side distance attenuation and 3D panning based on avatar coordinates” |
| Whitepaper §10.1 #4 “State & Media Offloading” | “3D spatial audio (Web Audio API PannerNode)” handled by LiveKit’s media path |
| Whitepaper §15 roadmap | “apply distance attenuation and Sanity-Linked Audio Distortion via Web Audio API” |
Architecture_Decision_Records_001-031.md, ADR-012’s record | “feeding the Babylon.js 3D Spatial Audio API (PannerNode) for client-side distance attenuation and 3D positioning” |
ADR-091 clause 1 rules the opposite for sound in general: audibility is server-computed, the server dispatches “already attenuated parameters positioned at the aperture”, and “the client renders them blindly and is never told the true source.” Clause 3 states the mechanism this protects against by name — a continuous attenuation “encodes distance-behind-the-aperture to whatever precision the number carries, and a player who can read it can triangulate.”
Why the two never met. ADR-091’s subject is a concealed entity making a sound — R7’s worked
example is a monster growling behind a wall. Player voice arrives on a different transport (the
SFU, ADR-012), is authored by a person rather than by the simulation, and was specified three
rounds earlier. Nothing connected them, and no lint can: one is prose about PannerNode and the
other is prose about occluders.
The honest scope, because overstating this would be the corpus’s own recorded failure mode. This is a scoping defect, not a shipped leak, and the distinction is load-bearing:
- Where the speaker’s actor is in the listener’s disclosed set, panning on its coordinates discloses nothing new — the listener is already drawing that token at that position. This is the common case at a table and it is fine.
- Where the speaker’s actor is not disclosed, §8.2’s per-viewer filter means the client has no
coordinates for it.
PannerNodehas no input, so the specified computation cannot run. The sentence describes a calculation over data the client does not hold. - The oracle appears in exactly one case: a client that pans on a position it holds for an undisclosed actor — a last-known position, or §5.1.1’s advisory position uplink. Then the attenuation curve inverts to a distance and ADR-091 clause 3 applies verbatim.
So the defect is that the specification does not say which of those three it means, and the
plausible reading — “attenuate by distance to the speaker” — is the one that requires the third.
Nothing is built. ADR-157 records that “§7.1’s voice subsystem does not exist” and ADR-166
that “no AudioContext is opened anywhere”, so this is being fixed before it has a
consumer, which is the cheapest point it will ever be available at.
Disposition: ADR-167 clause 3.
2. What a Discord Activity can actually do with voice
Read from the published surface on 2026-08-12. This is a third-party API and the re-check trigger is stated in §5.
The per-user voice write was removed, not merely absent. The SDK’s own v1 migration guide —
docs/embedded-app-sdk-v1-migration.md in the upstream discord/embedded-app-sdk repository, not
a file of ours — lists under Removed Commands:
setUserVoiceSettings, setVoiceSettings, getVoiceSettings —
alongside the removed events VOICE_SETTINGS_UPDATE and VOICE_CONNECTION_STATUS. The Commands
enum in src/schema/common.ts carries no per-user voice write; its only voice-adjacent entries are
SELECT_VOICE_CHANNEL and SET_CERTIFIED_DEVICES, and src/commands/ has no voice module.
What survives is read-only. VOICE_STATE_UPDATE, SPEAKING_START, SPEAKING_STOP. The
UserVoiceState schema carries volume and mute — as observations. There is no command that
sets either.
WebRTC is unavailable to an Activity at all. Discord’s networking guide states
“WebRTC is not supported” and “we currently only support websockets”; all traffic is forced
through the <application_id>.discordsays.com proxy. This is the fact with the widest blast
radius here, because the VTT’s own spatial audio (ADR-012) is WebRTC — so inside an Activity
both the Discord path and our own path are closed, for different reasons.
Discord’s own proximity-chat guidance points somewhere we cannot go. It routes the feature
exclusively through the Social SDK — a native integration calling
StartCallWithAudioCallbacks to take over the audio pipeline. It does not mention Activities. The
supported path for this feature exists and is not reachable from a web iframe.
Three properties of the removed API, recorded so its return is not mistaken for a solution. It
required rpc.voice.write, an approval-gated scope; it set local playback rather than a
server-side mix, so it was never authoritative; and it governed the native RPC transport. Scope
approval does not restore a command deleted from the embedded surface.
ADR-167 — Spatial audio inside a Discord Activity is refused on platform grounds rather than preference; the speaking ring is the sanctioned affordance and is gated on the disclosed set; and player-voice panning is scoped under ADR-091
Status: Accepted Date: 2026-08-12
Context
ADR-166 ruled that inside a Discord Activity capturesMicrophone is false, on three grounds:
double capture of one microphone, §7.1’s panner fighting Discord’s mixer, and doubled upstream —
plus the benefit that it solves the corporate-firewall ICE failure. Every one of those is true.
All four are reasons a competent engineer could trade away. “We chose not to run a second
voice stack” invites “so let us run it carefully” — and the obvious next proposal is to keep
Discord for transport and add a thin LiveKit session purely for spatialisation, or to ask Discord
for the volume control that the RPC API is widely known to have had. Neither is possible, and
ADR-166 does not say so, because the question it answered was what should this client do rather
than what may it do. voice-plan.ts’s own comment records the weaker form: the Discord branch
“does not consult capabilities, deliberately” because “we are not going to use it either way”.
Separately, F-R40-01 records that the specification’s player-voice panning was never scoped
against ADR-091.
Decision
1. Inside a Discord Activity, spatial audio is unavailable, and this is a platform prohibition.
Per-user volume, pan and mute were removed from the Embedded App SDK; WebRTC is not supported in an
Activity, so the VTT’s own SFU path cannot run there either. ADR-166’s ruling is re-grounded, not
replaced: capturesMicrophone: false is now the only expressible behaviour, not the preferred
one. The three reasons ADR-166 records are retained — they remain the reason the decision would be
right even if the platform changed.
2. The speaking ring is the sanctioned affordance, and its input is the disclosed set.
SPEAKING_START / SPEAKING_STOP drive a ring on the speaker’s token only where that actor is
already in the viewer’s disclosed set. Where it is not, the client renders nothing — ADR-091
clause 5’s fail-safe direction (“muffle more, or emit nothing”) in its degenerate binary case.
Two properties make this sound, and both are worth recording because the affordance looks like
the disclosure risk it is not. It is binary, so unlike a continuous attenuation there is no
precision to invert into a distance — no ordinal quantity is required and no Q-ID is introduced.
And it operates no channel Discord does not already operate: Discord’s own voice panel shows
who is speaking, to everyone in the channel, whether or not the Activity is running. The ring binds
speaking to a position the viewer already has, and adds nothing to either half.
3. Player-voice panning is scoped under ADR-091, and the fail-safe direction is flat rather than muffled. A client may pan and attenuate a player’s voice only on coordinates already in the viewer’s disclosed set. It may never pan on a last-known position, on §5.1.1’s advisory position uplink, or on any coordinate for an undisclosed actor. Where the speaker is not disclosed, the voice is presented unpanned and unattenuated, or not at all.
That inversion is the counter-intuitive half and is the point. The instinct is that a hidden
speaker should sound muffled — it is more atmospheric, and it is what the feature request asks
for. But muffling is the encoding: a flat voice says nothing about where its speaker is, and a
muffled one states a distance. Where concealment matters, flat is the disclosing-nothing option.
Atmospheric occlusion of a concealed speaker is available only through ADR-091’s existing
mechanism — server-computed, pre-attenuated at the aperture, quantised to Q-072 — and is not a
client-side effect.
This scopes the four locations F-R40-01 names; it does not delete them. The common case, a
disclosed party at one table, is unchanged.
Alternatives Considered and Why Rejected
- Run a LiveKit session inside the Activity purely for spatialisation, keeping Discord for transport. Rejected — not possible: WebRTC is unsupported in an Activity. It would also re-incur every cost ADR-166 records.
- Apply for the
rpc.voice.writescope. Rejected — the scope governs the native RPC transport, and the commands it authorises no longer exist on the embedded surface. Approval cannot restore a deleted command. Recorded because the RPC API’s per-user volume control is well known and this is the first thing a reader will propose. - Ship the Discord Social SDK. Rejected — it is a native integration and the Activity is a web
iframe. It is also a second voice stack and a third-party origin, against
frontend-guidelines.md§10. - Wait for WebTransport, which Discord says it is enabling. Rejected as a category error, and named because it reads like a path to voice: WebTransport is a data transport. It would not carry media tracks and would not restore a removed RPC command.
- Muffle a concealed speaker on their last-known position. Rejected — ADR-091 clause 3, verbatim. This is the naive implementation of the requested feature.
- Leave ADR-166’s grounding as it stands and record only the finding. Rejected: the reasons it records are tradeable and the constraint is not, so the decision would be re-litigated on its weakest argument.
Consequences (including the negative ones)
- The feature that motivated the integration is unavailable on the surface it was requested for. Proximity and occlusion audio are a standalone-client capability. Product and marketing copy must not claim spatial audio for the Discord path — the same discipline §6.1’s Honest Threat Model applies to the DRM claim.
- Two voice experiences across two surfaces, and a support burden: a player in Discord asking
where the VTT mute button went is answered by
voiceNotice, but a player asking why voices are not spatial in Discord and are elsewhere has no in-product answer today. - The ring needs a Discord-user → VTT-actor mapping that does not exist, and cannot be built
from the Discord identity alone without crossing the ruling
discordGrantsVttSession()enforces. It will have to come from the VTT session claiming a Discord user id, after login. - Clause 3 removes an atmospheric effect people will ask for by name. “Hearing someone through
a wall” is a genuinely good horror mechanic; it stays available, but only as a server-computed
ADR-091 event, which is more expensive to build than a
BiquadFilterNode. - Clause 1 is a bet that the platform does not change. §5 states the re-check trigger.
Enforcement (ADR-045)
Stated honestly, because most of this ADR is not enforced today.
- Enforced now:
src/audio/voice-plan.test.tspinscapturesMicrophone === falseandtransport === 'discord-native'for an Activity environment — the existing ADR-166 enforcement, which is exactly what clause 1 re-grounds. Nothing about clause 1 requires new code. - Not enforced, and by
P4these are therefore descriptions rather than requirements until their consumers exist: clause 2’s disclosure gate and clause 3’s panning scope. Neither has a subject — there is no ring, noAudioContext, and no voice subsystem. The intended enforcement points, recorded so they are not invented later: the ring’s input must be the same disclosed-set projection the renderer consumes, asserted in a spec that constructs a viewer with an undisclosed speaker and asserts zero rings; and the panner’s coordinate source must be that projection rather than any position store, which is a structural boundary (P3) available by giving the voice layer no reference to the advisory-position path at all. tools/check-network-boundary.mjscontinues to cover the third-party-origin half of the Social SDK rejection.
Links
Amends 012 (narrows its record’s client-side attenuation sentence to the viewer’s disclosed
set); Amends 166 (re-grounds its voice clause from preference to platform prohibition, and adds
the speaking ring its SPEAKING_START reference anticipated without deciding);
Depends-on 091 (the server-authoritative audibility rule, its clause 3 quantisation argument and
its clause 5 fail-safe direction, which clauses 2 and 3 apply to player voice);
Depends-on 034 (the single-producer visibility rule ADR-091 extends, and the disclosed set that
is clause 2’s and clause 3’s only permitted input); interacts with 157, 064, 005.
Introduces no Q-ID. Q-072 / Q-073 / Q-074 remain pending and this round does not set
them; clause 2 is binary and needs none, and clause 3 defers to ADR-091 where a level is required.
§11 Index Line
The row added to the whitepaper’s §11 is the authoritative copy. It is deliberately not
duplicated here — ADR-166’s record carries a copy of its own index line that has already drifted
from the whitepaper’s (the record’s copy still describes the ref callback as the shipped
mechanism and omits F-R39-04/F-R39-05), which is P6 reproducing itself inside a decision
record. One copy, in the index.
5. Re-check trigger for clause 1
Clause 1 rests on a third-party surface. It must be re-checked, and the trigger is not a date:
- a Discord release note restoring a voice-write command to the Embedded App SDK, or
- the Activity networking guide ceasing to say “WebRTC is not supported”.
Either event makes clause 1’s premise false and re-opens the question on ADR-166’s three original grounds, which are unaffected by it. Recorded here rather than as a calendar reminder because a dated re-check that finds nothing gets dropped, and this one has two specific observable events.
6. What this round did not do
- No code was written and no test was written. Clauses 2 and 3 have no subject to enforce against; §Enforcement says so rather than naming a check that does not exist.
- The Discord surface was read, not exercised. Every claim in §2 comes from the published SDK source, the migration guide and the developer documentation, retrieved 2026-08-12. Nothing was run against a real Discord client — the same limit ADR-166 records for its own handshake, and it means a behaviour that contradicts the documentation would not have been caught.
- The three whitepaper bullets and ADR-012’s record are scoped in place by clause 3; the prose around them was not otherwise revised.
F-R40-01is dispositioned but its subject is unbuilt, so the finding closes as a specification correction and will need re-checking when the voice subsystem is built — the point at which the scoping either holds or is discovered to be unimplementable.- The drift between ADR-166’s embedded §11 index copy and the whitepaper’s row is noted and not
fixed. It is a real instance of
P6and it is not this round’s subject; fixing it in passing is the thing the corpus’s own rules forbid. It is recorded here so it is not lost.
R40 Phase 1 — the CQRS read model, the CDC pipeline, and the AI session
Date: 2026-08-12 · Produces: ADR-168, ADR-169, ADR-170 · Findings:
F-R40-02This half of R40 is a build round rather than a review one. Everything below was written, compiled and tested before it was recorded, and three of the decisions are refusals of clauses in the brief that commissioned them.
7. What Phase 1 was asked for, and where the brief and the corpus disagreed
The brief asked for four things: Meilisearch with local Hugging Face embeddings, tenant tokens carrying entitlement filters, a PostgreSQL CDC pipeline over an ephemeral outbox with mandatory UUIDv7 keys, and a Microsoft Agent Framework integration held in a stateful Orleans grain.
Three of the four are adopted close to as specified. The fourth is adopted in shape and refused
in substance, and the brief’s own closing paragraph is the authority for that: “You are
empowered to override my instructions if you find a better architectural approach, provided you
follow P1 and P2 and log a new ADR.” The disagreements, found by reading the owning sections
before writing the code:
| The brief said | The corpus said | Disposition |
|---|---|---|
| Meilisearch is the search tier | §10.1: pg_trgm delivers search “without a second search tier to operate” | §10.1’s clause narrowed in place; pg_trgm keeps what it serves (ADR-168) |
| A search index for the RAG corpus | ADR-161 already accepted one — Tantivy, embedded | ADR-161 superseded, on the strength of its own recorded finding (ADR-168) |
| The AI grain applies game logic — updates HP | §7.2: “AI NEVER forces world state mutations” | Refused. The grain proposes; the game master rules (ADR-170) |
| Broadcast the state change to all clients in the room | §8.2 assembles per viewer; §8.3 forbids fan-out | Refused. One recipient, held as a type (ADR-170) |
| Prohibit auto-increment INTs for primary keys | db/marketplace has four, one of them a published SSE Last-Event-ID | Scoped to db/vtt rather than declared globally (ADR-169) |
Two things the brief asked for turned out to be already true, and saying so is P2.
PlayerId.New() has minted UUIDv7 since ADR-078, and every primary key in db/marketplace except
four is already uuid ... DEFAULT uuidv7(). The round did not discover a platform of integer keys;
it found one that had made the choice inconsistently and never written it down.
8. ADR-168 — The retrieval corpus leaves the process, and the embedder never leaves the machine
Status: Accepted · Date: 2026-08-12 · Supersedes 161 · Depends-on 097 (the read-only entitlement surface a tenant token’s filter is derived from) · Depends-on 043 (the rebuild watermark every T2 payload carries) · Depended-on-by 169 · interacts with 002, 042, 122
Context
§7.2 specifies a retrieval-augmented pipeline over PostgreSQL + pgvector whose chunks carry
sourceId, licenceId and ingestedAt, and whose retrieval “filters on the caller’s entitlement
for that licenceId”. §10.1 specifies pg_trgm over GIN as the search tier and claims it works
“without a second search tier to operate”. ADR-161 (R38) accepted a second T2 projection for
relevance-ranked free-text search — Tantivy, embedded in the silo — and recorded, in its own
row, the finding that makes this ADR possible:
ADR-002 makes the silo one deployable scaled horizontally, so an embedded index exists once per replica — two consecutive searches from one player can land on replicas at different positions and differ with no event having happened, a rebuild is N rebuilds, and memory is paid N times.
ADR-161 then makes that detectable and quotes ADR-043 back at itself: detection is not
protection. It is also not built — no C ABI, no [LibraryImport], no lifetime model for an
index object inside a virtual actor.
Decision
- The §7.2 retrieval corpus is an out-of-process search engine. One index, N replicas. This is not a new answer to ADR-161’s question; it is ADR-161’s own finding taken as the design input.
- The embedder is local, on the engine’s CPU, under a Hugging Face model — and the source is a
literal.
MeilisearchIndexwrites"source": "huggingFace"andMeilisearchOptionshas no source field at all. The engine supports OpenAI, Ollama and a generic REST embedder; each would send every chunk of licensed and user-authored text to a third party at index time. - The browser queries the engine directly, carrying a tenant token whose filter is the intersection of what it asked for and what ADR-097 says it holds. The request names candidate licences because §2.5 grants the VTT a keyed lookup and not an enumeration; every candidate is verified and the unheld ones are dropped, so naming a licence you do not own widens nothing.
- A caller entitled to nothing gets no token.
TenantSearchRuleshas one factory and it refuses an empty set. - The engine’s address is one configuration key. Moving it to a dedicated memory-optimised node
as the store grows is a change to
Descent:Knowledge:Meilisearch:Endpointand to nothing else.
Alternatives considered, and why rejected
- Build ADR-161’s Tantivy index as specified. Rejected on ADR-161’s own three costs, none of
which is fixable from inside the silo, plus §5’s rule that
Descent.Vtt.Geometry.Interopis the only project permitted[LibraryImport]— an FFI search index would have needed either a second exception or a home in the geometry assembly. pgvectoralone, extending §7.2 as written. Genuinely viable and the cheapest option: no new process, no new operational surface. Rejected on the tenant-token property, which is the one thing this decision is actually for. A browser cannot query PostgreSQL directly, so retrieval would be a silo endpoint per query — putting an unbounded external call on the request path and making the silo the fan-in that ADR-161 objected to for the opposite reason. Recorded as the strongest rejected alternative, because a future reader will re-propose it and should know it was assessed rather than overlooked.- A remote embedding API for better recall. Refused; see decision 2. It is the whole subject.
- A “matches nothing” filter for a caller entitled to nothing. Rejected because every spelling of one needs a sentinel value the corpus is assumed never to contain — a claim about data rather than about code, which the first chunk ingested under that licence silently converts from a deny into an allow.
Consequences, including the negative
- Positive. One index rather than N. The per-replica divergence, the N-fold rebuild and the N-fold memory ADR-161 recorded are removed rather than detected. Semantic retrieval exists without any text leaving the deployment.
- Negative — a second operational tier, which §10.1 claimed not to need. It has its own memory, its own store, its own backup question and its own upgrade cadence. §10.1’s clause is amended rather than left to be discovered.
- Negative —
Q-104is a revocation hole, not a TTL. The engine validates the token, not the entitlement. A licence revoked now stays searchable for up toQ-104. ADR-097 forbids caching entitlement beyond a room’s lifetime; the token is that cache, and this ADR bounds it rather than claiming to satisfy the rule. - Negative — the corpus is unbuilt above the plumbing. Nothing chunks a rulebook, nothing
ingests, and no
licenceIdis minted by anything. What exists is the index port, the adapter, the outbox, the consumer and the token path. Campaign-scoped chunks are absent entirely: §7.2 scopes themCampaign(RoomId, BranchId)and noBranchIdtype exists anywhere in this repository’s code, so inventing one to fill a field would commit the timeline engine to a key it never chose. - Negative — no measurement against
pg_trgmorpgvector. ADR-161 said this plainly about itself and it is equally true here: the case rests on capability and on the per-replica argument, not on a benchmark. Saying otherwise would imply one had been run. - The exit cost (
P8), recorded at adoption. Two files and one options section:MeilisearchIndex(the HTTP surface) andMeilisearchTenantTokenIssuer(the credential format). A different engine’s scoped credential is a different artefact — this is the part that would not survive a swap unchanged, and it is why the issuer is a port rather than a helper. The seam is registered inupgrade-and-supersession.md§4.
Rights-holders (ADR-079)
No new class of data is retained by this decision, and that is the point of decision 2. The
corpus holds text §7.2 already specifies retaining, under the same sourceId takedown key and the
same licenceId entitlement key. What changes is where a copy lives — and a purge by sourceId
is one server-side filter delete rather than an enumeration, which makes §7.2’s revocation path
stronger rather than wider.
Enforcement (ADR-045)
| Property | Check |
|---|---|
| No remote embedder is nameable anywhere | Adr168KnowledgeBoundaryTests.NoRemoteEmbedderSourceIsNameableAnywhere |
| One swap point | ...OnlyTheInfrastructureAdapterNamesTheSearchEngine |
| One place turns entitlement into a filter | ...OnlyKnowledgeSearchAccessBuildsTenantRules |
| Corpus and §8.2 disclosure cannot reach each other | ...TheDisclosurePathCannotReachTheCorpus |
| The port cannot read | ...TheCorpusPortIsWriteOnly, ...APurgeTakesTheSourceKey |
| A token always expires and always carries a filter | MeilisearchTenantTokenIssuerTests, TenantSearchRulesTests |
| A request cannot widen its own scope | KnowledgeSearchAccessTests.The_token_is_scoped_to_the_intersection_and_not_to_the_request |
9. ADR-169 — The outbox that never holds a row, and the keys that never collide
Status: Accepted · Date: 2026-08-12 · Depends-on 168 (the corpus this pipeline fills, and the only consumer that exists) · interacts with 002, 042, 111
Context
ADR-168’s corpus has to be filled from data whose authority is PostgreSQL, in the same transaction that decided the data exists — otherwise a crash between the write and the index leaves the two disagreeing with nothing recording where. The classic answer is a transactional outbox drained by a poller. The brief added a second requirement that the classic answer fails: the payloads are text chunks, and an outbox that accumulates them makes the primary database carry a second copy of the corpus.
Separately, the brief requires UUIDv7 keys for Active-Active readiness. Checking that against the
tree first (P2) found the platform had already made the choice and never recorded it: PlayerId
mints v7, and every db/marketplace key except four is uuid ... DEFAULT uuidv7().
Decision
- A lightweight CDC consumer: one long-lived Npgsql logical-replication connection speaking
pgoutput. No Debezium, no Kafka, no message broker. - The outbox is ephemeral: the row is inserted and deleted in the same transaction. Logical decoding reads the write-ahead log, not the heap, so the consumer receives the insert with its full payload while the table never holds a live row.
- The publication publishes inserts only. A delete is not decoded at all, so the consumer cannot misread a tombstone as a chunk.
- The insert and the delete are one command text, so there is no second call to omit.
- Only a commit advances the slot. Delivery is at-least-once; the index is keyed on
ChunkId, so a replay overwrites. - A malformed payload is a counted refusal, never an exception. An exception kills the connection, the slot stops advancing, and retained log grows until the disk fills.
- Auto-increment primary keys are prohibited in the CQRS/CDC domain, and the prohibition is asserted against the migration text.
Alternatives considered, and why rejected
- A poller over a durable outbox with delete-on-read. Rejected because it needs its own scheduling, its own lease and its own ordering guard, and because running one alongside the WAL consumer would deliver every chunk twice with the deduplication living nowhere.
pg_cronor an Orleans timer sweeping a TTL. Rejected as answering the wrong question: both bound how long bloat lasts, and the ephemeral pattern means there is none to bound. They also add either a database extension or a timer whose failure is silent.- Wolverine’s delete-on-read inside the transaction. Closest to what was built, and rejected as
a dependency rather than as an idea — it is one framework’s implementation of a pattern that is
two SQL statements here, and
P8asks what replacing a technology would cost before adopting it. - A data-modifying CTE —
WITH published AS (INSERT … RETURNING id) DELETE … WHERE id IN (…). Written first, and it does not work. Every statement in a CTE shares one snapshot, so theDELETEcannot see rows theINSERTis creating and removes nothing. It compiles, runs, reports success and grows the table forever. Recorded because it is the first thing a reader will propose. - Retrofitting
db/marketplace’s four identity columns. Refused as out of scope, not as wrong:user_notifications.idis a published SSELast-Event-ID, so changing it is a wire-contract change in another bounded context underADR-M-001.
Consequences, including the negative
- Positive. A chunk reaches the corpus in the transaction that created it, with no second delivery path, no broker and no live row.
- Negative — three residual costs, and “zero bloat” is true of exactly one thing. The live row
count is zero. The row still becomes a dead tuple autovacuum must collect; the payload is
still written to the log; and
Q-105-sized chunks will mostly be TOASTed, so the TOAST table churns too. The claim is about the table’s live size and nothing else. - Negative — and this is the one that can take a database down: a replication slot with no consumer pins the write-ahead log indefinitely. The ephemeral design trades a loud failure (a table that grows, visible in every size report) for a silent one (a slot nobody is reading, visible nowhere until the volume fills). This is why the slot is created by the consumer and never by the migration, and why the gauge reports a negative sentinel for an absent slot rather than the zero a healthy one reports.
- Negative — the pipeline needs
wal_level = logicaland a role holdingREPLICATION. Neither is a default, and the second must not be the role ordinary queries run under. - Negative — there is no migration runner.
db/vtt/migrations/0001_knowledge_cdc.sqlis applied by hand, exactly asdb/marketplace’s four are. Saying so isP7. - The exit cost (
P8).EphemeralKnowledgeOutbox(two statements) andKnowledgeCdcListener(the decode loop). Both nameNpgsql.Replication. Replacing the mechanism with a broker would keepIKnowledgeChunkOutboxand replace the consumer entirely.
Enforcement (ADR-045)
| Property | Check |
|---|---|
No auto-increment key in db/vtt | Adr169IdentityColumnTests.NoAutoIncrementKeyExistsInTheCqrsSchema |
| Every key is a UUID, positively | ...EveryPrimaryKeyInTheCqrsSchemaIsAUuid |
| The publish deletes what it inserts | ...ThePublishCommandInsertsAndThenDeletesTheSameRows |
| The row is gone AND the message arrives | EphemeralOutboxTests.A_published_chunk_reaches_the_consumer_and_leaves_no_row_behind, against a real postgres:18-alpine with wal_level=logical |
| One bad row does not stall the slot | ...An_undecodable_row_is_skipped_and_the_stream_continues |
| An absent slot is distinguishable from a healthy one | ...The_slot_monitor_distinguishes_an_absent_slot_from_a_healthy_one |
| Retained log is observable | vtt_knowledge_cdc_slot_retained_wal_bytes |
| A key is version 7 in its byte order | ChunkIdTests.Byte_order_is_time_ordered |
ChunkIdTests.Byte_order_is_time_orderedasserts over the wire bytes and not overGuid.CompareTo, and the distinction is the reason the test exists. .NET compares aGuid’s first three fields as little-endian integers; PostgreSQL ordersuuidby its bytes. A test written the obvious way would be green and would say nothing whatever about the B-Tree contiguity that is the entire reason v7 was chosen.
10. ADR-170 — The AI proposes; the game master rules; nobody broadcasts
Status: Accepted · Date: 2026-08-12 · Depends-on 033 (the mailbox discipline a model call obeys by being dispatched) · Depends-on 164 (the dispatch-and-reenter shape reused rather than reinvented) · Depends-on 122 (the AI covenant that governs the day a real provider is configured) · interacts with 016, 092, 168
Context
The brief asked for a Microsoft Agent Framework integration whose chat history lives in a stateful
Orleans grain, whose completions come from a mocked IChatCompletionService returning strongly
typed JSON, and which — on receiving that JSON — “must apply the game logic (e.g., update HP) and
immediately broadcast these state changes to all connected clients in the room via SignalR.”
The first three are good architecture and are adopted. The fourth contradicts two rules this repository already carried, and it was found by reading §7.2 and §8.2 before writing the grain.
Decision
- The session is a stateful Orleans grain, keyed on
(RoomId, PlayerId)so it survives a reconnect, with its history inIPersistentState<T>. - The completion is dispatched off the mailbox and adopted by re-entry, ADR-164’s shape unchanged. A model call is ADR-033 class 3 work; awaiting one holds the activation for its whole latency.
- The provider is an offline mock and there is no other. No key, no endpoint, no client.
- The grain PROPOSES. It produces an
AiCombatAction— a record with no verb — inside anAiProposal. It applies nothing. - The proposal reaches ONE viewer: the one who asked.
IAiProposalSinktakes a singleViewerId, andSignalRAiProposalSinkholds anISingleConnectionSenderand never anIHubContext. - Acting on a proposal is an ordinary command. The game master accepts;
RoomGrainvalidates; the event stream records; per-viewer assembly decides who learns of it.
Why clauses 4 and 5 are refusals of the brief, and they are independent
Clause 4 — §7.2’s GM Authority Principle. “AI provides rule references and exact text citations only. AI NEVER forces world state mutations. Final ruling authority belongs 100% to the Game Master (GM/KP).” There is no qualifier in that sentence. A grain that mutated hit points on a model’s say-so would be the authority, and the game master would hold a veto over a change that had already happened — which is not a veto. The failure mode has no symptom: the game gets faster and more responsive, and nobody notices the authority moved until a ruling nobody made is disputed.
Clause 5 — §8.2, and this half is a disclosure defect rather than a governance one. A room’s
state changes reach viewers through per-viewer assembly, which has already filtered each snapshot
by what that viewer may see. A proposal names an actor, a hit-point delta and a narration.
Broadcasting one tells every player that the actor exists, is in the room, and was targeted —
for an actor half of them cannot see. SignalRViewerSnapshotSink was given an
ISingleConnectionSender precisely so Clients.All and Clients.Group are unreachable from the
delivery path; this sink is given the same type for the same reason.
What the brief wanted still happens. The state change is broadcast over SignalR — by the component that knows who may see it. What is refused is the shortcut.
Alternatives considered, and why rejected
- Apply the mutation and broadcast, as specified. Rejected on the two independent grounds above.
- Apply the mutation but deliver through per-viewer assembly. Rejected: it fixes the disclosure half and leaves the authority half, which is the one §7.2 is unqualified about.
- Propose, but send the proposal to the whole room so players can see the AI’s suggestions. Rejected on clause 5’s argument, which does not care whether the payload is applied or proposed — the actor’s existence is the disclosure.
- A stateless Web API with the history in a table. Rejected as the brief does: a client would re-send the conversation per turn, or the server would fetch and rewrite it per request.
- Give the agent tools. Refused before it was proposed, and
ChatRolehas noToolmember as the record of that: an agent that can invoke something is an agent that can mutate the world. - Let the model roll dice. Refused under ADR-016: a proposal carrying “I rolled 17” is a second random source no proof covers. If a proposal needs a roll, the room draws it on acceptance.
- Restrict
AskAito the room’s game master. Rejected as buying nothing: a proposal carries no authority, is delivered to the asker alone, and changes no world state. Where authority matters is where a proposal is acted on, and that isIntentIngress, where the check already lives.
Consequences, including the negative
- Positive. An AI surface exists with no model, no key and no cost; the shape it will need is built and asserted; and the two rules that would have been quietly retired are now build failures.
- Negative — there is no token quota, and this is the largest gap. §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-106bound concurrency and conversation length; neither bounds rate. With the mock the cost is CPU; with a real provider it is money. This must exist before one is configured. - Negative — a session that deactivates between the ask and the answer loses the question.
State is written once per turn, at completion, so
AskAsynccosts no storage round trip on a game master’s request path. The question is lost with the answer it never got. - Negative — the accept path is not built. There is no endpoint that turns a proposal into a command; a game master receives a proposal and has nothing to click. The command path it would use exists and is unchanged, which is why this is a gap rather than a design question.
- Negative —
Microsoft.Orleans.Runtimeis now a direct reference ofDescent.Vtt.Application. It adds nothing to the deployed closure (Microsoft.Orleans.Serveralready depends on it at the same version); what changes is that the layer holding the grains can name the persistence contract. - Negative — the in-memory grain storage provider is Development-only and is registered unconditionally. A missing provider fails at activation with “provider not found”, which reads as a code defect; a durable provider is the deployment’s choice and its name is one constant.
- ADR-122 clause 4 is NOT discharged. Its release-checklist item — that every configured
provider carries a binding zero-retention, no-training term — has no object, because no provider
is configured.
Adr170GmAuthorityTests.NoCompletionProviderReachesAModelis the reminder: it fails the build the day someone names one.
Rights-holders (ADR-079)
A new class of retained data does exist: the conversation. An AI session persists what a player
typed and what the model answered, keyed on (RoomId, PlayerId). It carries no Marketplace
subject — ChatTurn has no identity field at all, and the key uses ADR-078’s opaque PlayerId —
so an erasure reaches it by the same route it reaches everything else keyed that way. The prompt
text is authored by the player and is their content under ADR-122 clause 1, and clause 3’s
disclosure (“user content IS embedded and sent to the configured provider”) applies to it the day a
provider exists. Today it is sent to a mock in the same process and leaves nothing.
Enforcement (ADR-045)
| Property | Check |
|---|---|
| The AI layer cannot reach a room | Adr170GmAuthorityTests.TheAiLayerCannotReachARoomGrain |
| A proposal has no verb | ...AProposalCarriesDataAndNoOperation |
| The sink cannot broadcast | ...TheProposalSinkCannotBroadcast (three forbidden spellings) |
| The port admits one recipient | ...TheProposalPortNamesExactlyOneRecipient |
| No real provider exists | ...NoCompletionProviderReachesAModel |
| The ask returns before the model answers | AiSessionGrainTests.An_ask_answers_Dispatched_and_the_proposal_arrives_afterwards |
| Only the asker receives | ...The_proposal_reaches_the_asking_viewer_and_nobody_else |
| The conversation persists per session | ...The_conversation_is_persisted_and_survives_deactivation |
| Malformed output poisons no history | ...An_undecodable_completion_appends_nothing_to_the_history |
Q-106 refuses rather than evicts | ...A_session_at_Q_106_refuses_rather_than_evicting_its_oldest_turn |
| Outcomes are observable | vtt_ai_turn_outcomes_total, tagged proposed / undecodable / orphaned / unreachable |
11. F-R40-02 — the finding this phase raised
F-R40-02 — Descent.IntegrationTests.csproj carried a ProjectReference to
Descent.Vtt.Plugins.CoC7e, a project ADR-162 deleted on 2026-08-11.
MSBuild reports a dangling project reference as MSB9008 and continues, so the build stayed green
and printed the warning on every run of DescentVTT.Full.slnx for a day. Removed rather than
dispositioned, because there is no decision in it: the project does not exist and nothing in the
suite compiles against it.
What it says about the deletion round is the part worth keeping. ADR-162 removed 41 349 lines
across two solutions and four projects, and the one artefact it missed was the one MSBuild would not
fail on. A reference that cannot be resolved is a build error; a reference to a file that is not
there is a warning — and CI does not build with -warnaserror, so nothing was going to catch it.
12. What Phase 1 did not do
-
Nothing was measured. There is no benchmark against
pg_trgm, none againstpgvector, and no figure for what the local embedder costs per chunk. ADR-168’s case rests on ADR-161’s per-replica argument and on the tenant-token capability, and saying otherwise would imply a measurement that does not exist (P7). -
Meilisearch was never started. No container, no engine, no index. The adapter’s HTTP shapes — the settings
PATCH, the documentsPUT, the delete-by-filterPOST— are written from the engine’s documented API and have not been executed against it. The tenant token’s format is asserted against its own specification (three base64url segments, HMAC-SHA256 over the first two,searchRules/apiKeyUid/exp), which proves the token is well-formed and not that this engine accepts it. That is the largest untested surface in the phase and it is named rather than implied by a green suite. -
The CDC pipeline WAS executed end to end, against a real
postgres:18-alpinewithwal_level=logical, and it is the one part of Phase 1 whose central claim is verified rather than argued. It also found a defect in the migration on its first run —autovacuum_analyze_enabledis not a PostgreSQL storage parameter andALTER TABLErefuses it — which is the argument for the fixture applying the shipped file rather than a copy. -
Nothing chunks or ingests anything. There is no ingestion path, no chunker, and no producer of
licenceId. The corpus is empty and will stay empty until something fills it. -
Campaign-scoped chunks are absent, because no
BranchIdtype exists in this repository’s code. -
The proposal-accept path is not built, so a game master can receive a proposal and cannot act on it from the client.
-
The sweep is in
docs/audits/R40_Phase1_Mutation_Sweep.md— 21 mutations, 21 caught, and two that did not compile on the first attempt, which is the same trap R37 recorded and R39 walked into five times. Its §5 is the part worth reading: what the sweep could not cover, which is most of the Meilisearch surface, because the engine was never started. -
The frontend search client IS built, and this line said otherwise for one commit. Corrected 2026-08-12, the same day. §12 originally read “No frontend work.
apps/vtt-frontend-clientis untouched … The client half is owed” — which was an accurate report of a narrowing this round chose, not of a constraint. Nothing in the corpus objected to building it; it was scoped out and stated plainly, and stating a gap is not the same as being entitled to leave one.src/network/knowledge/now holdstenant-token.ts(acquisition, four outcomes kept apart) andknowledge-search.ts(the query path), with 29 tests and its own mutation sweep.The brief’s “ensure the frontend ALWAYS queries Meilisearch using these tokens” is now discharged on both sides, and the client half is STRUCTURAL rather than procedural:
search(query)takes no token parameter, so none can be omitted or supplied; the engine’s URL is built in the one function that also sets theAuthorizationheader, so the two cannot be separated by an edit that looks like a refactor; and there is no anonymous mode and no fallback when acquisition fails. The strongest of the three is not a test at all — deleting the guard that returns early without a token does not fail an assertion, it fails to compile, because the acquisition type is a discriminated union andacquired.tokenexists only inside the narrowing.knowledge-boundary.test.tsasserts the other two against the client’s own source. -
What is still owed on the client: the
aiProposalhandler. The silo delivers a proposal to one game master’s connection and nothing renders it. That is the same gap as the missing accept path, seen from the other end, and it belongs to ADR-170 rather than to ADR-168.
R40 Phase 2 — the bake ladder moves to the creator’s machine, and three of the brief’s mechanisms are refused on measurements
Date: 2026-08-12. Status of this section: written at decision time, like §7–§12 above.
This phase is a build round with three refusals in it, and every refusal has a number behind it rather than a preference. That distinction is the round’s own subject: Phase 1’s two refusals (ADR-170) rested on rules this repository already carried, and two of this phase’s rest on things that were run. The third rests on a rule — ADR-098 — and is the one with a security consequence.
| # | What the brief asked for | Disposition |
|---|---|---|
| 1 | Relocate descent-asset-decoder off the backend | The premise is wrong (F-R40-03). It was never on the backend, and the crate with the missing mesh loader is a different one |
| 2 | Mesh loader, collision, light probes, KTX2, FlatBuffer | Built. ADR-171 |
| 3 | Client-side chunking, uploaded straight into PostgreSQL | Chunking built; the upload refused. ADR-172 |
| 4 | Workloads hot-loaded via AssemblyLoadContext | Measured impossible; the requirement met by the existing seam. ADR-171 |
| 4 | No fake DRM wrappers | Turned from a paragraph into a build gate. ADR-172 |
| 5 | ONNX Runtime local inference | Admission and session built; generation not. ADR-172 |
| 6 | Orleans UseLocalhostClustering LAN silo | Built, as the one non-AOT module, on a measurement. ADR-173 |
13. F-R40-03 — the brief names the wrong crate, and the right one was already where it was asked to go
The finding. The brief’s mission 1 says: “In R38, the descent-asset-decoder was built but
left without a mesh loader. Running this on the VTT backend exposes us to severe CPU exhaustion and
high cloud costs. […] Migrate the descent-asset-decoder logic out of the backend server
infrastructure.” Three claims, and each is about a different crate than the one named.
core/descent-asset-decoder is §6.1’s chunked AES-GCM-256 asset decryption core (ADR-153). Its
own module documentation says so in its first line. It is a zero-import wasm32 module instantiated
by the browser’s Streaming Worker — apps/vtt-frontend-client/src/streaming/wasm-decoder.ts is
its host — so it has never run on the backend, it does not decode meshes, and “relocating it to the
client” is a description of where it already is. Moving it into Studio would break §6.1, because
the thing that needs to decrypt a chunk is the play client.
The crate with the mesh-loader gap is tools/descent-asset-baker (ADR-158), and ADR-158 states
the gap in as many words: “there is no mesh loader, so the pipeline draws the footprint VOLUME
rather than the asset — a right silhouette and a wrong interior […] nothing calls the baker, so
ADR-005’s obligation is still enforced by nothing.”
Why this is a finding rather than a typo. Two of the brief’s three claims would have been
acted on: a round that took the instruction literally would have moved a decryption core out of
the browser worker that instantiates it, on a CPU-cost argument about a backend that never ran it.
The names are one word apart and the crates are unrelated. P2 is the rule that catches this —
read the file rather than reasoning from what a crate is probably called — and it is recorded here
because the next brief will make the same substitution.
Disposition. The decoder is untouched. Everything mission 1 and 2 ask for is done to the baker, which is where it belonged, and ADR-171 is that record.
14. ADR-171 — The bake ladder gets a mesh loader and moves to the creator’s machine, as a compute module the Launchpad cannot load in-process
Status: Accepted · Date: 2026-08-12
Context
ADR-158 built the top-down 2D tile ladder and recorded two gaps as its largest: no mesh loader, and no caller. ADR-005 §2.2 makes the tile a registration gate — a bundle without one is refused Profile C availability — so a gate nothing runs is a gate that does not exist.
The brief adds a deployment argument for moving the work, and it is right: heavy asset baking on the
silo is CPU the tick cannot afford (architecture-rules.md §3 forbids unbounded work in a mailbox,
and §4 gives the tick 50 ms) and cloud spend for a computation whose input is already on the
creator’s disk.
It also specifies a mechanism for the move, and the mechanism cannot execute.
Decision
-
The bake ladder gains a mesh loader, an occluder hull, a light-probe field, a KTX2 encoder and a FlatBuffer payload, all in
tools/descent-asset-baker.mesh.rsreads a GLB with every node transform applied, which is the half a naive importer skips: a loader that read primitives and ignored the tree is correct for a single-node asset and wrong for every kitbashed one (Studio_Architecture.md§5.3), and wrong quietly — the tile renders and the parts are in the wrong places. -
The tile is drawn with a depth buffer. Without one the last triangle submitted wins every pixel, so the tile shows whichever part of the asset came later in the glTF’s node order. Height maps to depth so the highest surface survives, which is what looking down at something means. The footprint pass ADR-158 built survives and is used for an asset with no geometry.
-
“Collision mesh” means the top-down occluder hull, in
Q-037fixed point, and not a 3D convex decomposition.Descent.Geometryis the only thing in this product that answers a collision or line-of-sight question and it is 2D —occluder.fbscarries wall segments as fourint32, andschemas/README.mdsays “the transform is 3D on the wire and 2D in the geometry engine, deliberately”. A 3D hull would be a correct answer to a question nothing asks, which isarchitecture-rules.md§9.1’s failure mode: an enumeration complete with respect to the wrong axis.The quantisation happens before the hull and not after, and that ordering is the decision rather than an implementation detail. Quantising afterwards produces a polygon whose vertices are on the grid and whose edges may not be convex — three nearly collinear hull vertices can round into a reflex turn — so every consumer downstream holds a polygon labelled convex that is not. Quantising first makes the hull exactly convex on the grid the geometry engine uses, with the predicate evaluated in integers, so there is no epsilon and no cross-host disagreement.
The hull is convex and therefore over-occludes a concave silhouette. That is the safe direction: ADR-091 clause 5 requires every visibility fallback to fail toward concealment, and an asset that hides more than it should is a rendering complaint where one that hides less is a disclosure defect.
-
Light probes carry sky visibility and not irradiance, and the schema says so. The baker has no lights — an asset is authored alone and placed in a room the pipeline has never seen — so what can be computed locally is the occlusion half, which is the half that survives the asset being used somewhere else. Baking a room’s lighting into a reusable asset is a failure that looks correct in the room it was baked for. The sample directions come from a fixed Fibonacci sequence rather than a random generator, because the ingestion gate re-derives and compares the bake (
Studio_Architecture.md§6 step 3) and a non-reproducible artefact makes that comparison impossible to write. -
asset_bake.fbsis the tenth wire schema and the first generated for three languages.protocol-and-wire.md§1 makes the.fbsset the only source of truth for anything crossing a process boundary, and this payload crosses the strongest one available: written by a binary the platform does not run, stored by a third party, decoded months later by a client built from a different commit. §4’s pin table gains two rows —tools/descent-asset-baker/build.rsandstudio-ci.yml’sFLATC_VERSION.The Rust arm is in TypeScript’s position rather than C#‘s, and that is why the pin is checked in
build.rs. Generated C# assertsFlatBufferConstants.FLATBUFFERS_<version>, so a skew is a compile error. Generated Rust carries no such assertion: a skewedflatcproduces code that compiles and decodes incorrectly. -
The bake payload and the RAG chunks travel separately, and the schema records the absence. Carrying the chunks in the R2 object would put §7.2’s retrieval corpus behind a signed URL, and a corpus that can be fetched whole is not one that can be entitlement-filtered per query.
-
The compute module is a Rust binary speaking
Descent.Studio.Contracts.Ipc’s framed JSON, and it is ADR-109 decision 3’s second kind rather than a new kind. That contract is a byte format — a four-byte little-endian length prefix and a UTF-8 object with a fixed field set — and nothing in it is a .NET type. §1.6.3 registers the seam as a P8 swap point and names “rewritten in another language” as a thing the seam exists to allow; this is the first exercise of a property that was specified and never tested.
The brief’s AssemblyLoadContext mechanism is refused on a measurement
The brief: “Official heavy features (e.g., KTX2 Compressor, Mesh Baker) must be implemented as
downloadable workloads and hot-loaded natively via .NET AssemblyLoadContext to retain full
GPU/Disk access.”
Measured 2026-08-12, a Native AOT publish with the Launchpad’s exact property set:
IsDynamicCodeSupported = FalseIsDynamicCodeCompiled = Falsector AssemblyLoadContext: OK ("probe")AssemblyLoadContext.LoadFromAssemblyPath: PlatformNotSupportedExceptionAssemblyLoadContext.LoadFromAssemblyName: FileNotFoundException — no metadata for this assemblyAssembly.LoadFrom: PlatformNotSupportedExceptionand both call sites raise IL2026 at publish, which IlcTreatWarningsAsErrors — already set on
both Studio executables — turns into a build error. So the Launchpad cannot compile the call, and
a Launchpad that somehow did could not execute it.
Studio_Architecture.md §1.6.4 predicted this re-proposal in as many words: “It will be re-proposed
by anyone arriving from a JIT .NET background, and the answer is not ‘we prefer not to’ — it is that
the artefact cannot execute.” Recording the measurement rather than pointing at the sentence is
the difference between a rule and a rule someone can check.
The requirement behind the mechanism is already met and met better. §1.6.1 places the Asset Manager in the compute-module row for reasons independent of AOT — crash isolation, memory isolation, cancellation by killing the process, OS-level resource limits on a bake — and a child process has full GPU and disk access, which an ALC-loaded assembly would have had no more of.
Alternatives considered, and why rejected
- Abandon Native AOT for the Launchpad to get the plugin model back. Refused, and
Studio_Architecture.md§1.6.4 already refuses it: §2 takes AOT for no-runtime-install and startup, and trading the product’s deployment model for a plugin mechanism inverts the cost. - Write the baker’s compute module in C# and P/Invoke the Rust crate. Refused as the worse of two shapes: it makes the module ADR-109’s third kind (a C-ABI shared library in the host’s address space), which §1.6.1 marks rare because its fault becomes the host’s fault — for a workload whose input is a creator-supplied glTF, which is exactly the untrusted-parser case.
- A 3D convex decomposition for collision. Refused; see decision 3.
- Basis Universal (UASTC/ETC1S) block compression. Refused for now and recorded rather than
quietly omitted. There is no pure-Rust encoder at shippable quality; the real one is a C++ library,
and adding a C++ toolchain to a creator-machine build is an exit cost that belongs in its own ADR.
What is lost is VRAM rather than bandwidth — Zstandard over RGBA8 makes the download small and
writeTexturestill receives uncompressed texels — and the honest statement is that the cost lands onQ-033, whichdocs-and-adr-workflow.md§4 flags as absent in a way that makes the corresponding protection non-existent. - GPU-accelerated KTX2 encoding, which the brief’s mandatory directive asks for. Refused as a category error for the rung that exists: Zstandard is a serial entropy coder with a sequential match window and no useful GPU implementation, and a mip chain is memory-bound. The GPU is used on the rung above — the adapter ladder draws the tile — so the directive is satisfied where it applies, and a GPU path here would be a claim rather than an acceleration.
Consequences, including the negative
flatcnow has a third language and a sixth pin location. A version bump is a larger change than it was, and the Rust arm is the one that fails silently if the pin is missed.- The baker links
wgpu,gltfandzstd, so the Studio module carries a graphics stack. Nothing in the silo or the client links it (architecture-rules.md§6 keeps it undertools/), and that boundary is unchanged. - The hull’s convexity loss is permanent for any consumer that treats it as the asset’s outline. It is documented in the schema, and the failure direction is stated, which is the most that can be done for a lossy step that has a consumer.
- The tile is now a function of the creator’s GPU driver. ADR-158’s cross-adapter assertion — no channel differing by more than 2/255 — was made on one machine’s five adapters; the mesh pass has not been re-checked across adapters, and that is named in §17 rather than assumed to carry over.
Enforcement (ADR-045)
bake-module in studio-ci.yml: clippy under -D warnings and the crate’s suite, including
module_channel.rs, which starts the real module binary and drives it over real pipes. The mesh
loader’s node-transform rule is a_child_node_accumulates_its_parents_transform; the hull’s winding
is the_hull_is_counter_clockwise; the KTX2 container is read back by the ktx2 crate rather
than by a reader in this repository, so a self-consistent mistake fails.
Links
Amends 158 (closes its mesh-loader gap and gives its ladder a caller; its placeholder rung and
its cross-adapter argument are unchanged) · Depends-on 109 (decision 3’s compute-module kind and
§1.6.3’s framed-stdio seam, whose language-independence this is the first exercise of) ·
Depends-on 005 (the mandatory 2D bake and its registration-time refusal) · Depends-on 017
(the Q-037 fixed-point domain the hull is quantised into) · interacts with 091, 147,
156 · introduces no Q-ID.
Why no Q-ID. The numbers this decision introduces are ceilings on work and fidelity knobs —
MAX_VERTICES, MAX_PROBES, the probe grid’s default, the zstd level. P5 governs numbers a
normative claim rests on, and none of these has one; registering them would create quantities whose
only citation is their own definition. The numbers it cites — Q-017, Q-033, Q-037, Q-105 —
are all existing rows.
§11 Index Line
See the whitepaper’s §11, row 171.
15. ADR-172 — The creator’s machine chunks the text and asserts no provenance; a model is admitted by hash; and the Honest Threat Model becomes a build gate
Status: Accepted · Date: 2026-08-12
Context
Three of the brief’s requirements share one subject — what a creator’s machine is permitted to assert — and separating them would have produced three ADRs that each answer a third of the same question.
Phase 1 built the corpus (ADR-168) and the pipeline that fills it (ADR-169). Neither has a producer: §12 of this file records that “nothing chunks or ingests anything”. The brief supplies one, and supplies it with an instruction that would make the producer authoritative.
Decision
-
The creator’s machine chunks and does not upload.
chunker.rssplits a document into ~500-word chunks on §7.2’s terms, and the compute module writes a submission to a path the caller named. It holds no R2 credential and no PostgreSQL connection string. -
The brief’s “these extracted chunks must be uploaded to PostgreSQL” is REFUSED, and the grounds are ADR-098, which this repository already carried: “Descent Studio is an untrusted producer; the ingestion gate is the trust boundary.”
Studio_Architecture.md§6 puts it as “the gate […] assumes nothing about the client that fed it”.The consequence is not abstract, and one of the four fields is the whole argument. A
KnowledgeChunkcarries six fields; a submission may carry two. Of the four the gate derives:licenceIdis what ADR-168 clause 3 makes retrieval’s entitlement filter. A creator who could name it could write text into a licence they do not hold, and every player entitled to that licence would retrieve it as material the platform had vouched for. This is a cross-tenant corpus injection with a citation attached.sourceEventSeqis §2 rule 2’s rebuild watermark. ADR-161 requires a payload behind the watermark to be refused rather than clamped; a client-chosen value defeats that outright, because a submission claiming sequence 0 sits ahead of every rebuild there will ever be.chunkIdis UUIDv7 (ADR-169), whose leading 48 bits are a millisecond. A creator’s clock is not this platform’s ordering.ingestedAtis when the corpus accepted it, which differs from when a machine wrote it by however long the upload took — the interval a takedown is argued over.
The refusal is structural.
CreatorChunkSubmissionis a record with two properties, andA_submission_has_no_field_a_creator_could_assert_provenance_withfails if a third appears. -
The expensive half still moves, which is what the brief was right about. Splitting a 900-page rulebook is CPU work with no trust content — a wrong split produces a worse citation, not a disclosure — and it is exactly the work that should not be on the silo.
-
Chunk boundaries are preserved in a stated order, which is the mandatory directive’s actual requirement: a chunk never crosses a heading or a page break; within a section it splits on paragraphs; an oversized paragraph splits on sentences; and only an oversized sentence is cut, with the cut reported on the chunk. A citation that begins in “Grappling” and ends in “Falling Damage” is worse than a short chunk, because §7.2 promises exact text citations.
-
A model is admitted by SHA-256 or not at all. An ONNX file is a program: the format resolves external-data paths named inside the graph, resolves custom operators by loading a shared library, and parses attacker-shaped protobuf in native code in-process.
LocalInferenceSessionhas no public constructor andOpenAsynchas no overload without a digest, so “load this file” is not an expressible operation.What the pin does not claim is stated because a reader could take it for more: a hash says the bytes are the bytes someone intended. A pinned malicious model is admitted. What it removes is the class where the file changed between the decision and the load.
-
The Honest Threat Model becomes a build gate. §6.1,
Studio_Architecture.md§9 row 3 and the Marketplace whitepaper all forbid an anti-tamper wrapper, and nothing checked.P4requires a normative rule to name its enforcement point;tools/studio/check-no-drm-wrapper.mjsis it, and it reads the two places a protector actually arrives — restored packages and post-publish CI commands.It has no per-line escape marker, and the first draft did. An exemption anyone can take is how a
MUSTgets quietly demoted (docs-and-adr-workflow.md§5). -
Velopack for all four RIDs, unsigned, and the workflow says so. One installer toolchain for one product on four platforms; three per-platform toolchains would be the P8 problem in the place where a divergence is invisible until a creator on the third platform cannot update.
Alternatives considered, and why rejected
- A Studio-held PostgreSQL credential, scoped to inserts on the outbox table only. Refused. It
bounds the blast radius of the credential and not the authority of the payload:
licenceIdis a column, so an insert-only grant still permits the cross-tenant injection above. This isarchitecture-rules.md§9.5 — detection is not protection — applied to a permission grant. - Accept a creator-supplied
licenceIdand validate it against their entitlements at read time. Refused. It moves the check to the wrong side of a cache: ADR-168’s cost is already that a revoked licence stays searchable forQ-104, and validating at read time makes an injected chunk searchable for the same window rather than never. - Sign the submission with the creator’s key and trust its fields. Refused on
Studio_Architecture.md§6’s own words: “Steps 3 and 4 are the trust boundary. Steps 1 and 2 are convenience and attribution. An architecture that treats signing as safety has confused authentication with authorisation.” - Parse PDFs in the bake module. Refused for this round. A PDF text extractor is a parser for a hostile format, and putting one in the process that also runs a GPU pipeline over creator-supplied geometry compounds two attack surfaces for a convenience. The module takes a text layer; what produces it is named in §17.
- Ship
Microsoft.ML.OnnxRuntime.DirectML. Refused as a packaging decision taken in the wrong place: it replaces the base package rather than adding to it, so referencing both puts two copies of the native runtime in one output directory and the winner is whichever MSBuild copied last.ExecutionProviderLaddernames the rung and selects the CPU one, so adding it later is an edit in a named place. - An obfuscator “just for the launcher”. Refused, and the reason is not that obfuscators do not work — several do what they claim. Adopting one changes what the platform may say, and the platform has told creators in writing that it makes no protection guarantee. A claim the product cannot keep sets a creator’s expectations against their own revenue.
Consequences, including the negative
- The ingestion gate has no HTTP endpoint.
KnowledgeIngestionGateis an Application-layer type with a suite; nothing calls it, and the publication pipeline that would supplysourceEventSeqand theSourceIddoes not exist. The refusal in decision 2 is therefore enforced on a path that has no traffic yet — which is the right time to enforce it and is not the same as it being in use. Q-105is now checked in two places, on the creator’s machine and at the gate. That is duplication with a stated reason (the client is untrusted), and it is a place the two can drift: the Rust side countschars and the C# side counts UTF-16 units, which agree except on astral code points, where the Rust side is conservative.- The DRM gate cannot see a protector applied by hand on a release engineer’s machine. It binds the repository; the release process is a human commitment. Saying so is the point rather than a caveat.
- The Velopack packages are unsigned, so a Windows install raises SmartScreen and a macOS one is refused by Gatekeeper without a right-click. Recorded here because a reader could take “we have installers now” for “we have shippable installers now”.
- The AI module answers no prompt. It admits a model, opens a session and reports the graph’s
inputs and outputs. Generation needs
Microsoft.ML.OnnxRuntimeGenAI— a KV cache, a sampler, and a model directory in its own layout rather than a single.onnx. - “Llama-3-8B-Q4” is not an SLM and the corpus should not repeat the label. Eight billion parameters at four bits is ~4.5 GB resident before the KV cache; small language model conventionally means roughly one to four billion. The target is not wrong — it is what a 16 GB machine can run — and the word is, because “small” is what makes “runs on the creator’s laptop” sound free.
Rights-holders (ADR-079)
No new class of data is retained by this decision, and the check is worth recording rather than skipping. The chunk text is the creator’s own content, already covered by ADR-122 clause 6’s publish-time disclosure and by §7.2’s corpus. The model file never leaves the creator’s machine and its digest is not retained anywhere. What is new is that a creator’s text is processed by software on their own hardware before upload, which retains nothing.
Enforcement (ADR-045)
honest-threat-model in studio-ci.yml for decision 6, with a positive control per prohibited term
in check-no-drm-wrapper.test.mjs — a list whose entries never match would report “clean” forever.
KnowledgeIngestionGateTests for decisions 1–3, including
A_submission_under_a_licence_the_caller_does_not_hold_is_refused_whole and the structural
A_submission_has_no_field_a_creator_could_assert_provenance_with. ModelAdmissionTests for
decision 5. chunker.rs’s suite for decision 4.
Links
Depends-on 098 (Studio is an untrusted producer and the ingestion gate is the trust boundary —
the whole of decision 2) · Depends-on 168 (the entitlement filter licenceId becomes, which is
what makes a client-asserted one an injection) · Depends-on 169 (the outbox this gate publishes
to, its UUIDv7 rule and its Q-105 bound) · Depends-on 122 (clause 6’s publish-time AI
disclosure, which already covers the text this pipeline moves) · interacts with 109, 111,
171 · introduces no Q-ID.
§11 Index Line
See the whitepaper’s §11, row 172.
16. ADR-173 — LAN Play is a self-contained JIT module because Orleans measurably cannot be AOT-published, and UseLocalhostClustering is not a safety property
Status: Accepted · Date: 2026-08-12
Context
The brief’s mission 6 asks Studio to host a single-node Orleans silo behind Kestrel so a group can play with no cloud dependency. It is a real gap — every other path in this platform assumes a silo someone is paying for — and the shape it asks for is the right one.
Two things about it are not what they appear.
Decision
-
Descent.Studio.LocalServeris a compute module (ADR-109 decision 3’s second kind), and it is the ONE Studio executable that is not Native AOT. Measured 2026-08-12, Orleans 10.2.2 withWebApplication.CreateSlimBuilder+UseOrleans(silo => silo.UseLocalhostClustering())under the Launchpad’s exact property set:-
171 IL2xxx/IL3xxx diagnostics — 60 × IL2026, 56 × IL3050, and the rest spread across IL2055/IL2070/IL2091/IL2067/IL3002. Under
IlcTreatWarningsAsErrorsthat is 171 errors. -
With the errors waived the publish succeeds, producing a 26.9 MB native binary, which then fails at startup:
System.InvalidOperationException: A suitable constructor for type'OrleansCodeGen.OrleansRuntime.Metadata_OrleansRuntime' could not be located.The trimmer removed the constructor of Orleans’ own generated metadata type.
This is “a publish that succeeds and a binary that runs are different facts” —
studio-ci.ymlalready says it about the Launchpad — met for the third time in this repository. -
-
What is given up is startup, not the deployment model. ADR-109 §2 takes AOT for two named reasons: no runtime install and startup.
SelfContainedkeeps the first — the module carries its own runtime, so a creator still installs nothing — and gives up the second, paid by a background process a game master starts once per session rather than by the window they open to get somewhere else. Size goes the other way: roughly 90 MB self-contained against 26.9 MB AOT.This narrows
Studio_Architecture.md§1.6.1’s second row rather than abandoning it, and it narrows nothing else. §1.6.2’s prohibition is on a module being a managed assembly loaded at runtime by the Launchpad; this is a separate process with its own runtime, nothing enters the host’s address space, and the Launchpad’s own AOT gate is unchanged and still enforced on four RIDs. -
UseLocalhostClusteringgoverns Orleans CLUSTER MEMBERSHIP and says nothing about where Kestrel listens. The brief puts them in one sentence — a silo started withUseLocalhostClusteringhosting “127.0.0.1:5000or local IP” that other players can reach — and the two are independent. The Orleans call configures a one-node cluster with a development membership table and loopback silo and gateway ports; the socket a player touches is Kestrel’s alone.Conflating them is the defect this clause exists to prevent, because it makes “localhost clustering” read as a safety property when it is a membership provider.
-
A LAN room binds where a game master named, and a wildcard is refused.
LanBindinghas two factories:Loopback(), which takes no address, andTryLan(address, port), which refuses0.0.0.0and::. Not a boolean flag — a flag defaults, and this is the setting that must not.The reason is what a LAN room contains. It is an Orleans world with a game master’s whole campaign in it, including everything §8.2 conceals from the players in it. A wildcard means every interface the machine has, which on a laptop is the hotel Wi-Fi, a VPN tunnel to an employer’s network, and whatever a docking station is plugged into. On a home network that is the intended feature; elsewhere it is the campaign handed to strangers, with no symptom the game master sees.
-
Joining needs a per-session secret, 128 bits in Crockford base32, compared in constant time. “On the same Wi-Fi” is not an authorisation — ADR-166 already made the general ruling when it made
discordGrantsVttSession()a named, testedfalse: a fact about how someone reached the service is not a fact about who they are.What it buys is exactly one property and the limit is stated: a player must have been told the room exists. It does not distinguish two players, it does not survive being read over a shoulder, and it travels over plain HTTP on a network where a peer can see traffic. A self-signed certificate was considered and refused: it makes every client refuse the connection until someone clicks through a browser warning, which trains the behaviour a certificate exists to prevent.
-
The cloud providers are swapped for memory. A LAN session’s durability requirement is “survives a grain deactivation”, not “survives the building losing power”.
Alternatives considered, and why rejected
- Waive
IlcTreatWarningsAsErrorsfor this module and publish AOT anyway. Refused on the second half of the measurement: the binary that results does not start. A gate relaxed to admit a broken artefact is worse than no gate. - Host the silo inside the Launchpad process. Refused twice over: the Launchpad is AOT and this cannot be, and a silo in the window process means an Orleans fault takes a creator’s unsaved work with it — §1.6.1’s own argument for the compute-module row.
PublishTrimmedwithout AOT. Refused: the trimmer is what removed the constructor, so the failure is the same one arriving in a creator’s session instead of in this build.- Bind to
0.0.0.0with a warning in the UI. Refused.architecture-rules.md§9.5 — detection is not protection — and this is the one setting in the module that can publish a campaign. - A local account system instead of a shared secret. Refused as building an authentication system inside a play-and-bake tool, for a feature whose premise is zero cloud dependency.
- SQLite grain persistence. Not refused; not built, which is a different word.
AdoNetgrain storage against SQLite is a real option and it is a schema plus a migration path for a session that currently needs neither.
Consequences, including the negative
- Studio’s module set is no longer uniformly AOT, and a future reader will find one
csprojwithPublishAotabsent. Its comment carries the measurement and the condition for reversing it: Orleans publishing clean and the resulting binary activating a grain. - The LAN module roughly triples Studio’s download when installed. It is an independently-installed workspace (§1.5.2), so a creator who never presses “Start LAN Room” never fetches it — which is the property that makes the size acceptable rather than a regression.
- The join secret is a bearer token on plain HTTP. Stated in clause 5 rather than mitigated.
- The room is not packed as an installable workspace yet. It is built, tested and published for
four RIDs;
pack-workspaces.mjsdoes not know about it, so--checkdoes not see it. - No player can actually join.
/lan/joinaccepts the secret and answers; there is no room, no grain, no SignalR hub and no game behind it. What exists is the host, the binding rule and the secret — the three things that had to be decided before any of that is safe to build.
Enforcement (ADR-045)
LanBindingTests for clauses 3 and 4, including A_wildcard_address_is_refused over both 0.0.0.0
and ::. RoomJoinSecretTests for clause 5. LocalSiloHostTests starts a real Orleans silo
behind a real Kestrel on an OS-assigned port — not a TestServer, because the finding this ADR
rests on is that Orleans publishes and then fails to start, and a double for either half would have
exactly that blind spot. studio-ci.yml’s native-aot matrix publishes the module for all four
RIDs.
Links
Amends 109 (narrows §1.6.1’s second row: a compute module is a self-contained native executable
or, where a measurement forbids AOT, a self-contained JIT one — the managed-assembly prohibition
of §1.6.2 is untouched) · Depends-on 002 (the “cluster means replicas of one binary” rule that
makes a single-node silo a coherent object rather than a degraded deployment) · Depends-on 166
(the ruling that reaching a service is not an identity, applied here to network adjacency) ·
interacts with 016, 034, 091, 172 · introduces no Q-ID.
Why no Q-ID. The port default and the secret’s entropy are the two candidates. Neither
supports a normative claim anywhere in the corpus, and docs-and-adr-workflow.md §4’s Pending
status exists for a quantity whose absence is the finding — which is not the case here.
§11 Index Line
See the whitepaper’s §11, row 173.
17. What Phase 2 did not do
P7 and CLAUDE.md §6 rule 5. Longer than usual again, and for the same reason: the round built
five things and the gaps between them are where the next round starts.
- Nothing was measured for performance. No bake was timed, no probe field was profiled, no KTX2
compression ratio was recorded against a real texture. The crate has a
MAX_PROBESceiling and no measurement behind the default grid. - No texture is read out of a glTF.
ktx2.rsexists and is tested; the path that extracts a base-colour image from a bundle does not, because it needs an image decoder over creator-supplied bytes — a security decision rather than plumbing. SoBakedAsset.textureis always absent today. - No PDF is parsed. The chunker takes a UTF-8 text layer. Extracting one is the missing half of the brief’s mission 3 and is named in ADR-172’s alternatives.
- The mesh pass was not re-checked across adapters. ADR-158 asserted no channel differing by more than 2/255 across five adapters for the footprint pass. The mesh pass adds a depth buffer and a vertex stage, and that assertion has not been re-run against it.
- Setext headings are not recognised by the chunker — a line of dashes is also a table rule and a thematic break, and guessing wrong splits a table in half.
- The ingestion gate has no HTTP endpoint and no caller, so ADR-172 decision 2’s refusal guards a path with no traffic.
- The AI module answers no prompt, and the ONNX package is unmeasured under Native AOT rather than known-impossible. The probe is cheap and is not run.
- No player can join a LAN room, and the LAN module is not packed as a workspace.
The three non-Windows AOT rows have never run.They ran, and all four are green —win-x64,linux-x64,osx-arm64andosx-x64, each publishing the Launchpad, the Forge, the LAN module and the AI module and then executing--checkon the published binary. (Answered 2026-08-12 by this branch’s own CI, which is what the paragraph above said would answer it.) What that establishes is narrower than “Studio is cross-platform”: four RIDs compile, link and pass a headless self-check on a clean runner. Nobody has opened the window on macOS or Linux, Photino’s own AOT probe was taken on Windows only, and the two JIT modules published without being started.OI-V-06narrows to that rather than closing.Velopack has never produced a package on this repository.It ran on the merge and all four RIDs produced a package —win-x64,linux-x64,osx-arm64,osx-x64. (2026-08-12.) The sentence this replaces said “its first run is the merge”, so it was written knowing it would go stale within the hour; correcting it rather than leaving it isP6applied to a line that predicted its own expiry. The gating is unchanged and still deliberate —native-aotpublishes and RUNS all four RIDs, so a green matrix is the evidence, and packaging on a pull request would add four runners, two of them macOS, for an artefact deleted in seven days. What it does NOT establish:vpk packproduced a file. Nobody has installed one, nobody has run an update through it, and the packages are unsigned — so a Windows install still raises SmartScreen and a macOS one is still refused by Gatekeeper. “Studio is ready for local offline deployment” is not what four green packaging jobs prove.every_adapter_bakes_the_same_tilehad never run in CI either, and its first run failed: a bareubuntu-latesthas no graphics stack at all, and that test fails rather than skips because “a skipping test reports green for a pipeline nobody ran”. The fix is to installmesa-vulkan-drivers, which gives the runner ADR-158’s software tier. That closes ADR-158’s own last line — “lavapipe is named and untested” — which had been open since R38 for the reason this round discovered: nothing ran the crate in CI.
18. The mutation sweep, and the two mutants that were equivalent rather than surviving
23 mutations, 10 Rust and 13 C#, all killed after two rounds. The first round is what this section is for: four entries did not kill anything, and only two of them were test defects.
What the first round reported
| outcome | count | what it meant |
|---|---|---|
| killed | 16 | the assertion existed and fired |
| survived | 2 | one real test defect, one equivalent mutant |
| anchor missed | 2 | the sweep’s own bug, and it is recorded because it looked like a pass |
The anchor misses are the reason anchor is a distinct outcome that fails the run. The tree is
CRLF and two multi-line anchors were written with bare newlines, so they matched nothing. A sweep
that reported those as “clean” would have claimed the level-order and cross-product mutations were
killed when neither had been applied — which is the shape of every false green this corpus records.
Both killed on the second round, and the cross-product one is the mutation that reproduces the i64
overflow the suite found while ADR-171 was being written.
The one real test defect
ktx2.rs’s mip filter rounds, and nothing asserted it. The module says truncation “darkens
every level by up to one unit per channel, which compounds down the chain into a visibly darker
distant LOD”, and the suite checked level lengths and round-tripped level zero — which is the
one level that is never averaged. the_mip_filter_rounds_rather_than_truncating now pins it on a
2×2 whose mean is 1.75, where truncation gives 1 and rounding gives 2.
The two equivalent mutants, and why each is worth a paragraph
architecture-rules.md §9.6 says a rule satisfiable by writing a sentence is not enforced. An
equivalent mutant is the same defect one level down: a check that cannot fail.
-
chunker.rs’sQ-105guard. Swapping the early return’schars().count()forlen()changed nothing observable, because the loop beneath it still counts characters and produces the same pieces. The guard is an optimisation and the loop is the bound — and the module’s comment claimed the opposite, saying byte-counting there “would refuse valid CJK chunks at a third of the allowance”. It would not. The comment is corrected, the mutation is retargeted at the loop, andthe_ceiling_is_counted_in_characters_and_not_in_utf8_byteswas rewritten to overshoot the ceiling by ten characters — at exactly the ceiling both answers give one chunk, which is why the original test proved nothing. -
RoomJoinSecret’s post-loop length check.consumed != TextLength || bytes.Count != EntropyBytes— deleting the first half changed nothing, because too many characters are already refused inside the loop and 26 is the only count that yields 16 bytes. The clause was dead, and a dead clause reads as a guarantee while providing none, so it is deleted rather than kept for reassurance. The mutation now targets the in-loop bound, which is what actually rejectsSECRET + "0".(That mutation is itself the residue of a defect: the decoder accepted a 27th character until
A_prefix_of_the_secret_does_not_matchcaught it during Phase 2’s first Studio test run. The sweep then found that the fix had two halves and only one was load-bearing.)
A third near-miss is recorded as equivalent and NOT fixed. LocalSiloHost.StopAsync clears
_secret, and deleting that line survives: StartAsync reassigns it, so no reachable state
differs. It is memory hygiene rather than behaviour, and inventing a test that reaches into a
private field to pin it would be pinning the implementation. What replaced it is a mutation against
Binding = null in the same block, which is observable — a stopped room still reporting a URL is
a Launchpad showing an address nobody can reach — and
Stopping_a_running_room_clears_what_it_reports_about_itself kills it.
What the sweep could not cover
P7, and the list is longer than the sweep.
- Nothing in
render.rs. The mesh pass needs a GPU adapter, so a mutation there is killed or not depending on the machine — which makes it a measurement of the runner rather than of the suite. ADR-158’s cross-adapter assertion has the same shape and the same limit. - Nothing in either module’s
Program.csbeyond the handshake.module_channel.rsdrives the Rust binary over real pipes and the C# modules have no equivalent harness, because the Launchpad side of that seam isModuleProcessTestsand it starts the Forge. - Nothing in
LocalInferenceSession’s ONNX path. No model is bundled, so the only reachable arms are the admission refusals — which are swept — and the runtime’s own parse failure. - Nothing in the four-RID CI matrix. A mutation to a workflow is only killed by running it, and
the three non-Windows rows have never run (
OI-V-06). - The sweep’s own restore step had a bug. It read and wrote
utf-8-sig, which strips a BOM on the way in and adds one on the way out, so five files came back byte-different after a mutation that had been reverted. Caught bygit statusrather than by anything in the sweep, and recorded here because it is exactly the class the sweep exists to find: a step that looks like a no-op and is not.
19. F-R40-04 — a .NET output rule swallowed a Rust source directory, and only CI could see it
The finding. .gitignore line 51 is **/[Bb]in/*, from GitHub’s Visual Studio template, and it
means build output. Cargo’s convention is that src/bin/*.rs is source: every file there is an
additional binary target. The two conventions collide on the word bin.
So tools/descent-asset-baker/src/bin/descent-bake-module.rs — ADR-171’s whole compute module, the
caller ADR-158 said the ladder lacked — was never committed. The working tree had it, cargo test
ran 70 tests against it including the seam test that starts it as a real process, cargo clippy
was clean, and the commit did not contain the file.
Every local signal was green, and that is the part worth recording. This is not a test that failed to catch something; it is a class of defect no test on this machine can catch, because every one of them reads the working tree. The first evidence was the runner:
error: can't find bin `descent-bake-module` at path /home/runner/work/DescentVTT/DescentVTT/tools/descent-asset-baker/src/bin/descent-bake-module.rsIt is the argument for bake-module existing as a CI job, and it arrived on that job’s first
run. A round that had tested the crate only locally would have merged a Studio compute module that
is not in the repository — and the failure would have surfaced later as “the Asset Manager
workspace cannot be built” with nothing pointing at an ignore rule written years earlier for a
different toolchain.
Disposition — scoped, not general. Two lines, negating only under src/:
!**/src/[Bb]in/!**/src/[Bb]in/**The .NET rule stays right for the twenty-odd */bin/ output directories git status --ignored
lists, so un-ignoring bin generally would trade one silent defect for a much larger one. Two lines
rather than one because a negation must re-admit the directory before anything inside it can be
re-admitted — git does not descend into an excluded directory to evaluate patterns against its
contents.
The general shape, which is the transferable half: a pattern inherited from one ecosystem’s
tooling binds every ecosystem in the repository. architecture-rules.md §6 already records the
same shape for a different tool — “a boundary enforced by one tool’s file-discovery rule is not
enforced against a different tool” — where NuGet did not walk the way MSBuild does. This is that
sentence again, with .gitignore as the tool and Cargo as the ecosystem that was not consulted.