Descent VTT — Architecture Decision Rulings R3
Descent VTT — Architecture Decision Rulings R3
Date: 2026-08-01 Basis: the ten findings of
Phase3_Ruling_Adversarial_Review.md(R3-F-01…R3-F-10) Status of this document: six new ADRs (072–077) = Accepted; one status change (ADR-046 →Conditional); four section instructions. Revision status: the whitepaper body was fully revised per these rulings on 2026-08-01 (§4.3, §5.2, §5.3, §7.1, §7.3, §7.4, §7.5, §8.2, §9.5, §9.6.6, §11, plus the ADR-062 record inOpen_Items_S1_and_BENCH04.md).This round deliberately opens no W7. R1 needed W1–W6 because 30 ADRs spanned six topics and required an intermediate layer to organize the revision order; R3 has only 6. And one of this round’s most important findings is precisely that intermediate documents drift — W6’s closing status still claimed “the whitepaper has not yet been modified” long after the whitepaper had been revised, and the instruction in
Open_Itemsnaming a write-back to W6 was never executed. One more intermediate document to maintain is one more source of truth that will go stale. This file is therefore the ruling record, the body text is the sole implementation source, and there is no third thing between them.Writing convention (as originally recorded): following R1. ADR field names, identifiers, technical terms and API/library names remain in English; the body of the argument was written in Traditional Chinese. This document has since been translated in full; the convention is kept here as a record of how it was authored.
0. Ruling Summary
| Finding | Topic | Ruling | Produced |
|---|---|---|---|
| R3-F-01 🔴 | The room liveness quantity | A three-state model: Active / Dormant / Deactivated. A Dormant room suspends its tick (there is nothing to simulate), rather than lowering the tick rate | ADR-072 (amends 049, 067) |
| R3-F-02 🔴 | The degradation ladder and disclosure sets | No rung of the ladder may widen any viewer’s disclosure set. Subtractive deltas may not be collapsed; coarsening must resolve with AND rather than OR | ADR-073 (amends 048) |
| R3-F-03 🟠 | ADR-046’s premise is unbuilt | ADR-046 → Conditional; the entity-level degraded régime is declared; the field-level substrate gains a named owner | ADR-074 (amends 046; assigns an owner to §8.2 item 6) |
| R3-F-04 🟠 | The preview falls inside the refusal window | Checkout splits into two phases: construction + diff entirely outside the window, with the room usable throughout; only the commit enters the (millisecond-scale) swap | ADR-075 (amends 047, 070) |
| R3-F-05 🟠 | T0 snapshot format and migration | Snapshots are never upcast; a version mismatch discards and replays; but they are pre-generated ahead of the change, so the wake-up storm is scheduled rather than discovered | ADR-076 (amends 058, 020) |
| Methodology | Seven isomorphic “dependency” relationships | Add the Depends-on / Depended-on-by link type | ADR-077 (amends 045) |
| R3-F-06…09 🟡🔵 | Four local inconsistencies | Section instructions (section 7) | — |
| R3-F-10 🔵 | Reverse links | Fixed (71 rows, 0 gaps) | — |
ADR-072 — Room Liveness Is Three States, and a Dormant Room Suspends Its Tick Rather Than Slowing It
Status: Accepted · Date: 2026-08-01 · Amends: ADR-049, ADR-067
Context
ADR-067 claims a backgrounded client “ceases to count as room-activation liveness”, so that a campaign branch can genuinely sleep when nobody is playing. ADR-049 defines that quantity as “at least one active RoomGrain”.
These are not the same quantity, and the gap between them makes ADR-067’s claim impossible to satisfy: §5.2’s RoomTickScheduler unconditionally delivers an AdvanceTickAsync every 50ms, so a room is active for as long as it ticks, regardless of the foreground client count. The mechanism ADR-067 needs — “deactivate a room with zero foreground clients” — does not exist in the document.
And adding that mechanism would in turn break ADR-049 rule 4. That rule says in as many words that “overpaying is recoverable, suspending mid-combat is not”, and has keep-alive fail safe toward staying awake for exactly that reason. If “zero foreground clients” directly triggered deactivation and the end of keep-alive, then six players simultaneously switching away to check a rules PDF — an entirely ordinary minute at the table — would produce a Neon compute resume plus a room cold start, precisely what rule 4 exists to prevent.
Decision
Liveness is not a boolean but three states.
| State | Condition | Tick | Keep-alive | Grain |
|---|---|---|---|---|
| Active | ≥ 1 foreground client | 20Hz (§5.2 unchanged) | on | activated |
| Dormant | 0 foreground clients, ≥ 1 connected client, and no pending work | suspended | continues for Q-052, then stops | stays activated |
| Deactivated | 0 connected clients | — | off | existing Orleans behaviour |
Five normative rules:
- Entering Dormant requires the
Q-051grace period. The room stays Active forQ-051after the last client backgrounds. The entire reason for this rule is that “everyone checks their phone for a minute” and “everyone left four hours ago” MUST be distinguishable, and in the current design they are identical. - A Dormant room suspends its tick, and this is not a fidelity degradation. §5.2 specifies that 20Hz is an architectural constant, never silently halved — but that rule governs a room in progress. A room with zero viewers and no pending work has nothing to simulate: the tick’s output is per-viewer snapshots, and the viewer count is zero. This is “nothing to do”, not “doing it worse”, and the two MUST be recorded in different vocabulary and emit different metrics (
RoomDormant, notTickOverrun). - “No pending work” is a hard condition for entering Dormant, not a description. A non-empty Effect queue, a scheduled trigger, or an in-progress hydration/checkout/export saga — any one of these keeps the room Active. A script’s deferred effect does not stop needing to be applied just because nobody is watching.
- Unknown means Active — ADR-049 rule 4’s fail-safe is preserved verbatim here and extended. If the foreground signal is unavailable (an older client that does not implement
visibilitychangereporting, a lost signal, or a capability matrix showing the client lacks the capability), that client counts as foreground. The reason is exactly ADR-049’s, merely applied to a new signal. - The definition of
CampaignBranchSuspendedWhileRoomsActivenarrows in step. “Rooms active” now means state 1, not “grain activated”. Without this change, that metric would be permanently positive whenever a Dormant room exists and would lose its alerting value — a metric that silently fails because a new state was added is worse than no metric.
Amendment 2026-08-02 — Dormant describes a room that loaded; a room that never loaded is Abandoned
Written in English per the 2026-08-01 convention change.
Implementing hydration retry (RoomGrain.HydrateAsync, backend b9b785b) needed a
terminal state for a room whose event store stayed unreachable across every retry.
The obvious word was Dormant, and taking it would have been a defect: the two
states share no condition, no tick behaviour and no grain behaviour, and one of this
ADR’s own rules already separates them.
Dormant (this ADR) | Abandoned (hydration) | |
|---|---|---|
| Room state | Loaded. T0 exists and is current | Never loaded. There is no T0 |
| Trigger | Zero foreground clients, ≥ 1 connected, no pending work, after Q-051 | Hydration exhausted Q-063 attempts or Q-066 elapsed |
| Tick | Suspended — nothing to simulate | Never started — nothing to simulate with |
| Commands | Served; the room is intact | Refused, HydrationAbandoned |
| Grain | Stays activated | Releases the activation |
| Keep-alive | Held a further Q-052 | Held a further Q-067, for a different reason |
Rule 3 above already forbids the collision. “No pending work” is a hard condition for
entering Dormant, and it names an in-progress hydration saga as pending work. A
room still retrying its load is therefore Active by this ADR’s own definition,
and a room that has stopped retrying is not a room this ADR describes at all.
The grain-retention decision does not transfer, and that is the sharper
difference. This ADR keeps a Dormant room activated deliberately — deactivating
discards T0 and pays replay on the way back, and Dormant exists precisely to make
a short absence cheap. An abandoned room has no T0 to discard, so retention buys
nothing and it releases the activation. Its keep-alive window is not preserving
state; it absorbs caller retries so that re-activation does not clock a fresh
hydration attempt per inbound call, which would turn the backoff into a hot loop
driven from outside the room.
Metrics stay separate, for the reason rule 5 gives. RoomDormant counts rooms
that are healthy and idle; a room that cannot reach PostgreSQL must never enter that
count. The failure rule 5 describes is a term silently acquiring a second meaning,
and this is the first place it could have happened.
Q-051 and Q-052 remain this ADR’s quantities and remain pending. They are not
the hydration bounds and may not be borrowed for them: they measure the grace period
after a foreground client backgrounds, which is a product decision about how long
an absence is forgiven, and the registry records that their sum is what a player
experiences. The hydration bounds are Q-063…Q-067, minted separately.
Alternatives Considered and Why Rejected
- liveness = “at least one active
RoomGrain” (keeping ADR-049’s original sense): rejected. Under that definition ADR-067’s claim can never hold, and that claim is its reason for existing. - liveness = “at least one foreground client” (taking ADR-067 literally): rejected. It breaks ADR-049 rule 4, and without a grace period one collective phone check is one mid-session suspension.
- A Dormant room lowering its tick rate (to 1Hz, say) rather than suspending: rejected. §5.2 already devotes a whole passage to why halving the cadence is wrong (client interpolation buffers presuppose a fixed interval); and for a zero-viewer room 1Hz saves almost exactly as much as suspending while retaining all of the complexity plus a new fidelity state that would have to enter Guardrail 7. The difference between suspending and slowing is that suspending has a clean resumption point (resync) and slowing does not.
- Using connection count rather than foreground count as the Dormant criterion: rejected. This is precisely the failure ADR-067 already identified — “six phones left in the background all night” has a connection count of six.
Consequences (including negative)
- Positive: ADR-067’s claim has a mechanism for the first time; ADR-049 rule 4’s fail-safe is fully preserved.
- Positive: Dormant is a clean resumption point — ADR-067 already specifies that returning to the foreground takes a per-viewer resync, and that path applies verbatim to Dormant→Active with no new protocol.
- Negative: two new pending values (
Q-051,Q-052), with a real trade-off between them: too short aQ-051and a collective phone check interrupts the tick (players see a resync on return); too long and backgrounded rooms keep spinning. Too short aQ-052and Neon resumes repeatedly; too long and ADR-049’s savings do not materialize. Both MUST be measured, and it is their sum that a user actually experiences as “how long an absence is punished”. - Negative: the three-state model requires the §10.4 diagnostic envelope to add a “room state transition history”, or “why did I have to reload when I came back” cannot be answered.
- Negative: a Dormant room still holds its activation (memory), it simply does not tick. This is deliberate — deactivating discards T0 and pays replay on return, and Dormant exists precisely to make a short absence cheap. Memory is not the scarce resource in this architecture; mailbox time is (§4.4), so this trade goes in the right direction.
Enforcement
- Metrics:
RoomDormant(with the transition reason),RoomDormantDurationSeconds;CampaignBranchSuspendedWhileRoomsActivepermanently 0 under the new definition.RoomHydrationAbandonedis a separate metric and is never folded intoRoomDormant(see the 2026-08-02 amendment). - §14.6: the three-state transitions are injectable conditions — “everyone backgrounds”, “a single player returns to the foreground”, and “a deferred effect comes due during Dormant” each need a passing test (ADR-064).
- §9.5 Guardrail 7: the
Application backgroundedrow gains the three states and the grace period. ArchitectureTests:RoomTickScheduler’s delivery condition MUST read room state and MUST NOT be an unconditional timer.
ADR-073 — No Rung of the Degradation Ladder May Widen Any Viewer’s Disclosure Set
Status: Accepted · Date: 2026-08-01 · Amends: ADR-048
Context
Rung 2 of the degradation ladder in §5.3 is “collapse individual delta sets into their group set”. That sentence has two readings and the document specifies neither: discard the delta (that player switches to the group mask), or merge the delta into the group mask (the whole team acquires that player’s sight). The latter is an information disclosure driven by server memory pressure.
§5.3 has its own side-channel rule — “mask granularity may vary only with distance and capacity, never with the existence of any entity” — but it governs granularity, while rung 2 changes set membership. The rule’s wording does not reach it; the rule’s reasoning applies entirely.
Two things found while reviewing this item are more important than the original finding (see below).
Decision
Replacing the existing side-channel rule of §5.3, generalized into one machine-checkable property:
No rung of the degradation ladder may make any viewer’s disclosure set a superset of its disclosure set at full fidelity.
Three concrete rules follow from it:
- The direction of collapse is “discard the delta”, never “merge into the group mask”. The holder of the individual set falls back to the group mask; nobody else’s sight changes at all.
- Subtractive deltas MUST NOT be collapsed. A sparse delta is not necessarily a superset of the group mask — blindness, blinding spells, curses, or any condition that reduces the senses produce a subtractive delta. Collapsing a subtractive delta into the group mask would let that player see what they should not, which reverses the direction of rule 1. Rung 2 therefore qualifies only for purely additive deltas (darkvision, scrying, see invisibility); if a room has only subtractive deltas left to collapse, the ladder skips rung 2 and goes straight to rung 3.
- Coarsening MUST resolve with AND, not OR. Rung 3 coarsens distant chunks from 1m to 2m, merging four fine cells into one coarse cell. That coarse cell is marked visible only when all four fine cells are visible. Resolving with OR would disclose an entire 2m coarse cell because one of its 1m cells was visible — disclosing a sliver at the edge of cover that was not visible before, and doing so exactly when the room is busy. AND is the conservative direction: coarsening can only reveal less, never more.
Rung 2’s degradation is visible to the affected player, not only to the Keeper. ADR-048 originally required only a “Keeper-visible notice”. The consequence of rule 1 is that a player loses darkvision mid-combat — that is a rules-level change, and it happens to that player. Notifying only the Keeper leaves that player experiencing an unexplained loss of an ability.
Alternatives Considered and Why Rejected
- Collapse = merge into the group mask (reading b): rejected. It is an information disclosure, and the trigger for that disclosure is server memory pressure — which also makes it an inducible side channel (a player who knows the mechanism could widen the party’s sight by increasing room load).
- Keep §5.3’s original wording and merely clarify rung 2: rejected. The original wording enumerates only the granularity dimension, while this round’s problems are in set membership and in the coarsening resolution direction — patching one dimension would leave the other two, and the next dimension would be equally invisible. Stating it as “the disclosure set may not widen” is the only form that covers every dimension, and it can be asserted directly by a generative test.
- Allow collapse of subtractive deltas too, but apply a compensating mask first: rejected. That introduces a new visibility computation on the degradation path, and the degradation path is taken only when resources are already exhausted — adding computation there trades the failure mode “disclosure” for “cascading degradation”.
Consequences (including negative)
- Positive: the three rules together make the degradation ladder’s safety an assertable property rather than three individually correct pieces of wording.
- Negative: rule 2 weakens rung 2’s actual recovery yield. A room making heavy use of blinding/fear conditions (one of the core experiences of Call of Cthulhu) has mostly subtractive individual deltas, rung 2 is nearly ineffective for it, and the pressure falls straight through to rung 3. This is the honest result: the original design’s yield estimate counted subtractive deltas too, and that portion should never have been recoverable.
- Negative: rule 3’s AND resolution makes coarsened visuals more conservative — distant cover edges will be darker than reality. This is the right direction, but it is a visible image quality difference and needs to be written into that Guardrail 7 row.
- Negative: player-visible degradation notices add a class of user message, and it MUST be expressed in game language (“your darkvision is temporarily not working”) rather than system language (“visibility delta collapsed”).
Enforcement
- §14.7 generative property test (this ADR’s principal enforcement point): random room states, random viewers, applying the degradation ladder rung by rung, asserting that each rung’s disclosure set is a subset of the full-fidelity disclosure set. This one test covers all three rules and applies automatically to any rung added in future.
- §14.6: each rung can be forced by setting
Q-009arbitrarily small (ADR-064). - Metrics:
DegradationLadderStep; plus a newDeltaCollapseSkippedSubtractive(how often rule 2 takes effect — if it stays at zero long term, subtractive deltas are never occurring, which is itself worth being suspicious about).
ADR-074 — ADR-046 Is Conditional on Field-Level Version Granularity, and That Substrate Now Has an Owner
Status: Accepted · Date: 2026-08-01 · Amends: ADR-046 · and assigns an owner to §8.2 item 6
Context
ADR-046’s own Accepted cost column says it: rule 2 is viable only if authors can declare minimal premises, and that needs field-level version granularity; otherwise, in a busy round, every macro touching a combatant conflicts with every other one.
Meanwhile §8.2 item 6 records field-level visibility as a known design gap, noting that the two share a substrate.
ADR-046’s status is therefore Accepted while its own stated viability premise is an open gap. ADR-045 established Conditional for exactly this situation.
The more fundamental problem is ownership: the sentence “the two share a substrate and should be designed together” appears three times in the documentation (§4.3, §8.2, and Appendix B of Architecture_Decision_Records_063-071.md), and no ADR owns it. Recording the same thing three times with nobody responsible is why it would be carried into implementation.
Decision
- ADR-046’s status changes to
Conditional, on the condition “field-level version granularity is implemented”. - The régime before the condition is met is explicitly declared rather than left for implementers to discover:
- Premises are recorded at entity level.
- Rule 2 (a mismatch atomically invalidates the whole batch) MUST NOT be relaxed. This is ADR-046’s safety core (partial application would let an 8 HP target be killed twice by two macros of the form “if HP ≤ 10 then zero it and drop loot”, with both audit records legal). What degrades is the granularity, not the atomicity.
- The observable consequence of the degradation is named and measured: the
script:stalerate rises in busy rounds. TheEffectBatchStalemetric MUST be treated as an expected value rather than an anomaly during this period, and its baseline MUST be reset once field-level granularity ships — or that release will be misread as a regression. - The mitigation is narrowing the read surface, not relaxing the check: seams record the entities actually touched, so a macro reading only its own actor conflicts with no other macro. SDK documentation MUST promote “read only what you need” to first-class authoring guidance rather than a style suggestion.
- The field-level substrate gains a named owner: it is one workstream serving both ADR-046’s premise versions and §8.2 item 6’s per-field visibility, listed as a §13 cross-cutting workstream, and on its completion it simultaneously clears ADR-046’s condition and §8.2 item 6’s gap marker. The two MUST NOT be implemented separately — separate implementations would produce two field identification schemes, which is exactly the “the same data with two homes” failure ADR-004/058 already demonstrated.
Alternatives Considered and Why Rejected
- Keep
Acceptedand leave the granularity problem to implementation: rejected. Implementers would treat ADR-046 as a settled requirement per the index, entity-level premises would be built (they are viable), and the failure ADR-046 itself predicts would only appear in integration testing — at which point the cheapest fix is to relax rule 2, which ADR-046 explicitly forbids. An ADR should not place its own safety core on the path of schedule pressure. - Relax rule 2 to per-Effect discard to avoid the granularity requirement: rejected, for the reasons in ADR-046 itself (Guardrail 4 gives the same reason for intents).
- Implement field-level visibility and field-level premise versions separately: rejected (see decision 3).
- Implement field-level premise versions first and defer per-field visibility: rejected. Designed apart, the former would pick a field identification scheme insufficient to express visibility (covering only numeric attributes but not relations and collections, say), and that choice is extremely hard to change afterwards.
Consequences (including negative)
- Positive: ADR-046’s status is consistent with its own statements for the first time.
- Positive: a gap recorded three times with no owner acquires one.
- Negative: a
ConditionalADR is harder to read than anAcceptedone, and this is the second (ADR-062 was the first). The §11 index needs to show at a glance which decisions are currently not in force, orConditionalwill be read as a synonym forAccepted. The index’s Status column MUST show the condition itself, not merely the wordConditional. - Negative: the field-level substrate is real engineering work, and pulling it forward into Phase 2 will crowd out other items. This is a deliberate trade, justified by its clearing two gaps at once and by retrofitting costing far more than designing them together.
Enforcement
- §11 index: ADR-046 marked
Conditionalwith the condition text inline in the Status column. - §13: the field-level substrate is listed as a cross-cutting workstream, naming the deliverable as “field identification scheme + version recording + visibility projection” as a single whole.
- Metrics:
EffectBatchStaleis an expected value while the condition is unmet; the baseline is reset when the substrate ships.
ADR-075 — Checkout Is Build-Then-Commit: the Room Refuses Commands Only During the Swap
Status: Accepted · Date: 2026-08-01 · Amends: ADR-047, ADR-070
Context
ADR-047 specifies that during a checkout the Grain “refuses new commands with an explicit reason”. ADR-070 specifies that a checkout crossing a cartridge major “previews the diff to the GM before committing”.
A preview is a human decision point. The GM will think, consult a rulebook, leave their seat. For that whole period the room keeps refusing every player’s commands — and the entire reason ADR-047 exists is “a room must not be freezable by its own GM”. A rate limit bounds frequency and a debounce bounds continuous scrolling, and neither stops “leaving the preview open”.
Decision
Checkout splits into two phases, and the refusal window covers only the second.
Phase one — construction and diff (the room operates normally throughout):
- The saga replays and constructs the candidate state outside the mailbox, producing the diff at the same time.
- The Grain does not enter
TimeTravelingand refuses no commands. Play continues as usual. - The candidate state is tagged
(BranchId, SourceEventSeq, targetSeq)and retained forQ-053, then silently discarded on timeout (nothing was committed, so a timeout has no user-visible consequence). - Progress is reported as
CheckoutPreparing{progress}— an informational state, not a refusal reason.
Phase two — commit (millisecond-scale):
- After the GM confirms, the Grain enters
TimeTraveling, performs the atomic swap, broadcasts the per-viewer Full Snapshot (ADR-023), and leaves that state. - This window is bounded by the swap itself, containing no I/O and no human wait.
Premise checking (the third item of this ruling, and it is required by the phase split):
- The room keeps producing events during phase one, so the candidate state may be stale by the time the GM confirms.
- The candidate carries the
SourceEventSeqit was built at; at commit, if the room’s sequence has advanced, the candidate is invalidated and rebuilt, with an explicit explanation to the GM (“the room changed while you were reviewing”). - A stale candidate is never silently swapped in. This is the third application of the same discipline as ADR-046’s Effect premises and Guardrail 4’s intent causal chain — and that in itself is notable: three mutually independent mechanisms all need “read at T, applied at T+k” premise validation, which strongly suggests it should be a shared platform primitive rather than three implementations.
Alternatives Considered and Why Rejected
- Set a short timeout on the preview and keep the refusal window: rejected. Any timeout long enough for a GM to read a cross-version state migration diff is long enough to freeze the room until players complain; any timeout short enough not to freeze the room is too short for the GM to finish reading. This trade-off has no acceptable middle value, which is exactly why the phases are split.
- Split phases only for cross-major checkouts: rejected. An ordinary checkout has no reason to refuse commands during construction either — ADR-047 wrote it that way because it treated construction as part of the operation. Splitting is better for both, so this ADR amends 047’s general case, not merely 070’s special case.
- Freeze room state during phase one to avoid premise invalidation: rejected. That reinstates the refusal window under a different name.
Consequences (including negative)
- Positive: a room cannot be frozen by a checkout for longer than one atomic swap, however long the GM takes to decide.
- Positive: ordinary checkout availability improves along with it (it too used to freeze the room for the whole replay).
- Negative: the candidate state may be rebuilt repeatedly — an active room plus a slowly deliberating GM will invalidate candidates over and over.
Q-013’s rate limit still applies to rebuilds, so in the extreme the GM is told “the room is too active; pause play and try again”. That is the correct outcome: a cross-version migration should never be run mid-combat anyway. - Negative: the candidate state occupies memory for up to
Q-053, and there may be one per room. This must enter the §5.3 room memory considerations (note that this is a different budget fromQ-009’s visibility budget and the two MUST NOT be conflated). - Negative: a new pending value,
Q-053.
Enforcement
- Metrics:
CheckoutPrepareDuration,CheckoutCandidateInvalidated,CheckoutSwapDuration(the last of which has a CI-tracked p99 — it is the only part entering the mailbox). ArchitectureTests: the duration of theTimeTravelingstate MUST NOT span anyawaiton I/O or any path waiting on external input.- §14.6: “the room produces new events during phase one” is an injectable condition, asserting the candidate is invalidated rather than silently swapped.
ADR-076 — T0 Snapshots Are Never Upcast; a Schema Change Is a Scheduled Pre-Generation, Not a Discovered Replay Storm
Status: Accepted · Date: 2026-08-01 · Amends: ADR-058, ADR-020
Context
Three passages together constitute a problem with no mechanism:
- §10.1#3: all C#→cache payloads (including MemoryPack room snapshots) use MemoryPack.
- ADR-058: room snapshots in the cache are byte-identical to the T1 payload.
- §7.5.3: the disposition of T1 snapshots is “no action; written at the current schema version”.
The intersection of the first two implicitly settles something §7.1 never states — that the persistence format of T0 snapshots is MemoryPack. A normative requirement about the persistence layer exists only in the intersection of two passages discussing caching and serialization respectively.
And the third describes only writing, never answering reading: what happens when a snapshot written by v2 is read after v3 deploys? ADR-020’s upcasters are registered by (cartridgeId, eventType, fromVersion) — eventType, which does not cover snapshots; and MemoryPack is a strict-schema binary format, where adding one field makes old blobs undeserializable (the whole upcasting argument of §7.1 was written on the premise of JSON: “Legacy JSON payloads are upcasted…”).
The only safe behaviour is to discard and replay in full — correct, but an undeclared performance cliff: on every snapshot schema change, the next activation of every room degrades to a full replay, and avoiding exactly that is the entire reason snapshots exist (“Wake-up Storm”). A mechanism that exists to prevent a wake-up storm produces a platform-wide wake-up storm when its version is upgraded.
Decision
Five items:
- The persistence format of T0 snapshots is stated explicitly in §7.1, no longer inferable only from the intersection of §10.1 and ADR-058. The format is the same as the cache payload’s (MemoryPack), and that sameness is the content of ADR-058 — writing it in the persistence-layer passage is what makes it visible to a future reader changing the cache format that they are touching T1.
- Snapshots are never upcast. This is the opposite of events, and the reason is symmetric: events are evidence and MUST remain readable forever (§7.5.1 rule 1); a snapshot is an accelerator and, by the invariant of §2.1.1, is necessarily rebuildable from T1. Maintaining an upcast chain for a discardable artefact applies the cost structure of events to something that does not need it.
- Every snapshot carries a
SnapshotSchemaVersion; a version mismatch discards and replays. This is the safe default, and it is the only default requiring no chain to maintain. - But that replay MUST be scheduled, not discovered. A snapshot schema change is a migration-visible event (the same category as the
Q-020partition count):- The deployment declares the change.
- The ADR-043 worker pre-generates new-version snapshots ahead of the new version taking effect, shard-scoped and rate-limited, covering active and recently active rooms.
- Rooms not pre-generated fall back to replay — correct, merely slower — and the number of rooms that will pay that cost is known in advance, rather than being a surprise.
- Pre-generation itself is unrelated to
Q-051…; it uses ADR-043’s existing shard leases and rate mechanisms, adding no infrastructure.
- The cache key MUST include
SnapshotSchemaVersion. The current key contains(BranchId, SourceEventSeq). Without the schema version, an old-format blob would be served byte-for-byte correctly — ADR-058’s byte identity guarantees it arrives intact, and then fails to deserialize at the consumer. This is a failure produced by the intersection of two correct mechanisms, and the fifth appearance of that same shape this round.
Alternatives Considered and Why Rejected
- Build a snapshot upcaster registry (parallel to the event upcasters): rejected. It turns snapshots from “a discardable accelerator” into an artefact that must remain readable forever, contradicting the tiering of §2.1.1 — the value of the invariant T0 is exactly reconstructible from T1 lies precisely in T0’s persisted form being freely discardable. Maintaining a chain that is never necessary is pure cost.
- Switch to a lenient format (JSON/BSON) so adding fields does not break old blobs: rejected. It would require restating ADR-058’s byte identity and would introduce reflective serialization on the tick’s hot path — the reasons §10.1#3 chose MemoryPack (zero allocation, no reflection) still hold.
- Accept the replay cliff without pre-generation: rejected. It is viable and safe, but it converts a predictable cost into a capacity event that only manifests after deployment, and the whole point of §10.4 is that such events MUST be reasoned about in advance.
- Omit volatile fields from snapshots to reduce change frequency: rejected as the principal means (though it is good incidental practice). It lowers the probability without changing the mechanism, and this ADR wants a mechanism.
Consequences (including negative)
- Positive: snapshot version evolution has a mechanism for the first time, and that mechanism adds no infrastructure.
- Positive: decision 5 closes a failure that would present as “deserialization failure” while rooted in the cache key — the hardest class to attribute.
- Negative: pre-generation is a real compute cost, and it lands in the deployment window. A deployment with many recently active rooms will need a noticeable lead time, and this MUST enter the release process rather than an engineer’s memory.
- Negative:
SnapshotSchemaVersionis the third version that must be managed together (the other two beingprotocolVersionand the cartridge version), while ADR-054’s minimum-version handshake covers only the first. The relationship among all three needs explaining in §3.1. - Negative (stated honestly): this ADR takes §7.5’s migration classification from three classes to five: event payloads, read models, rules semantics, entity state (ADR-070), and T0 snapshots (this one). §7.5.1’s opening sentence — “the most expensive mistake is treating it as one problem” — is now more correct and also more in need of a table. After a classification has been extended twice, its value depends on whether somebody maintains that table.
Enforcement
- §7.1: the format and version field made explicit.
- CI: a shape change to the snapshot record MUST be accompanied by a
SnapshotSchemaVersionincrement (checkable by schema hash). - The cache key includes
SnapshotSchemaVersion(ArchitectureTestsasserts the key’s composition). - Release checklist: does this release change the snapshot schema; if so, has pre-generation been scheduled.
ADR-077 — Depends-on Is the Link Type the Governance Model Was Missing
Status: Accepted · Date: 2026-08-01 · Amends: ADR-045
Context
Seven of Phase 3’s ten findings have the same shape: ADR A claims a result, the mechanism realizing that result falls under ADR B’s jurisdiction, both are individually correct, and nobody owns the join.
| A claims | Mechanism belongs to |
|---|---|
| 067: the branch will sleep | 049’s liveness definition |
| 046: premise checking is viable | §8.2’s field-level substrate |
| 070: commit only after preview | 047’s window semantics |
| 058: byte-identical | §10.1’s format decision |
| 035: archived with the room | 036’s relocation mechanism |
| 059: the archive states its versions | 041’s trajectory table version |
| 054: flush first | 011’s acceptance semantics |
Amends captures none of them, because they do not “narrow a clause” — they are dependencies. The link types §11 currently has are only Supersedes/Amends/interacts with, of which interacts with is too weak (it creates no obligation) and the other two are too strong (they claim the target was changed).
Decision
Add a fourth link type: Depends-on / Depended-on-by.
- Semantics: “some result claimed by this ADR has its realizing mechanism under the target ADR’s jurisdiction.” The target is not changed, so it keeps its own status; but the target bears an obligation it may not know about.
- The result MUST be named when registering. The link is written
Depends-on 049 (liveness quantity), not a bareDepends-on 049. A dependency link without named dependency content cannot be evaluated during a revision check, and evaluation is the reason it exists. - The enforcement point — this is this ADR’s substance: when revising or superseding an ADR, its
Depended-on-byset MUST be listed and each named dependency confirmed still to hold. This is a named step of code review, not a suggestion. interacts withis retained but demoted to purely informational, and MUST NOT be used to express a dependency. If a relationship creates any obligation, it isAmendsorDepends-on.
The seven dependencies identified this round are registered along with it (see the index revision in section 6).
Alternatives Considered and Why Rejected
- Expressing dependencies with
Amends: rejected.Amendsclaims the target was changed, whereas in a dependency the target is often entirely unchanged — recording 067 asAmends 049would make 049 look revised when it was not. That would dilute the meaning ofAmends, and ADR-045 established it precisely for precision. - Expressing dependencies with
interacts with: rejected. It creates no obligation and so is useless for revision checking — which is exactly how the type asymmetry in the ADR-027/061 pair survived three months unnoticed (one end recordedAmends, the otherinteracts with, and the latter triggers no check). - Adding no type and instead “re-reading all 71 whenever any ADR is revised”: rejected. It is unexecutable, and an unexecutable rule is not a rule (ADR-045’s own enforcement-point rule requires this).
- Recording dependencies in each ADR’s Consequences prose: rejected. Five of the seven findings already had their dependency written in prose, and prose is not listed when the target is revised. The problem was never that the information did not exist; it is that it was not in the place that gets queried.
Consequences (including negative)
- Positive: the entire class to which this round’s seven findings belong gains a structural detection mechanism rather than case-by-case discovery.
- Positive: the cost of revising an ADR becomes visible — an ADR depended on in ten places costs ten confirmations to revise, and that number is itself a useful architectural signal (it indicates which decisions are load-bearing).
- Negative: registering dependencies is an ongoing discipline cost, and it will be missed. Unlike
Amends, missing aDepends-onproduces no immediate symptom — missing anAmendsat least fails the reverse-link lint (R3-F-10). The completeness ofDepends-ontherefore cannot be guaranteed by lint and can only be maintained by review habit. This limitation MUST be stated, or it will be treated as a guarantee. - Negative: the index’s Links column gets longer. This is acceptable — it gets longer because it finally records what was not being recorded.
- Negative (this ADR’s honest assessment of itself): it solves how not to lose a finding again after it has been found, not how to find it the first time. This round’s seven were read out by a human, and
Depends-ononly guarantees they will not be lost again at the next revision. No link type can substitute for reading two ADRs side by side.
Enforcement
- CI lint (an extension of the second of ADR-045’s three lints):
Depends-on/Depended-on-byreverse links are complete;Depends-onMUST carry named dependency content (non-empty parentheses). - Code review: when revising any ADR, list its
Depended-on-byset and confirm each item — a named item on the release checklist. - The §11 preamble records the four link types and their obligation levels.
7. Section Instructions (no ADR required)
Per the classification criterion of ADR-045 section 5, the following four neither contradict nor narrow an existing ADR, and reject no alternative likely to be re-proposed.
D-1 (R3-F-06) — The ADR-062 record gains the distinction between “edge inference” and “authoritative publication”
The core sentence with which ADR-032 rejects ADR-024 is “any mapping held by the edge layer is stale by construction”, and ADR-062’s mechanism is precisely a room→silo assignment table read by the edge router.
Add a Consequences passage to the ADR-062 record in Open_Items_S1_and_BENCH04.md: the difference is who owns that table. ADR-024 had the edge infer placement for itself (necessarily stale, because placement is decided independently by the §4.4 director); ADR-062 has the placement director publish the table itself (authoritative, because the writer is the decider). ADR-032’s criticism targets the former and does not apply to the latter.
Without that passage, a future reader comparing the two would reasonably conclude that 062 violates 032.
D-2 (R3-F-07) — ADR-059’s archive version list gains the trajectory table version
The current §7.3.2 text records “the Descent.Geometry revision and the cartridge version” for an archive. Add ADR-041’s trajectory table version — dice animation is a presentation event, and after a trajectory table revision an old campaign’s replay plays different motion, directly contradicting ADR-059’s own principle (“a record claiming to be history when it is not is worse than one that admits its limits”).
At the same time, change that list from an enumeration into a maintainable rule: “the archive records the version of every versioned first-party artefact affecting its presentation”, listing the currently known ones (the geometry crate, the cartridge set, the trajectory table, the font subset Q-032, the §9.4 effect classes). Enumerations go stale; rules do not — and this round’s finding is a product of an enumeration going stale.
D-3 (R3-F-08) — Intent refusals before a forced update MUST be visible to the user
ADR-054 specifies flushing “offline Intents and unmerged Yjs” before a forced update. A flush is a send, not an acceptance: ADR-011’s Timeline Rule refuses that queue as a batch when the room timeline has forked.
Add to §9.6.6: the result of the flush MUST be resolved before the forced update begins. If the batch is refused, the user first sees that “these actions could not be applied” review (the presentation form Guardrail 4 already specifies), and the update proceeds only after they confirm. The Yjs half has no such problem (a CRDT and branch-agnostic, so merging necessarily succeeds) — and the two being written in the same clause is precisely why this was overlooked, so the new text MUST state them separately.
D-4 (R3-F-09) — ADR-036 lists every room-scoped table that must migrate with the room
§7.4 claims explored FOW chunk rows and sampled diagnostic retention are “archived with the room”, while ADR-036’s mechanism names only mt_events and the Yjs blob table.
Add the complete list to §7.4, stating explicitly for FOW chunks that they have no seq_id, so the “must not be re-inserted into the active sequence range” argument of ADR-019/036 does not apply to them — this MUST be said rather than inferred by the reader, because that argument is the core of the section and a reader would reasonably assume it applies to every table the section mentions.
8. Index Revision (already applied to whitepaper §11)
- Six new rows, 072–077.
- ADR-046’s status changed to
Conditional, with the condition text inline. - The
Depends-onlink type added, along with the seven dependencies identified this round. - The preamble gains the obligation levels of the four link types.
9. New Pending Values
| ID | Name | Source | Status |
|---|---|---|---|
| Q-051 | ROOM_DORMANT_AFTER_MS | ADR-072 (the grace period staying Active after the last client backgrounds) | Normative (pending) |
| Q-052 | KEEPALIVE_AFTER_DORMANT_MS | ADR-072 (how long keep-alive continues after entering Dormant) | Normative (pending) |
| Q-053 | CHECKOUT_CANDIDATE_TTL_MS | ADR-075 (how long a candidate state is retained) | Normative (pending) |
It is the sum of Q-051 and Q-052 that a user actually experiences as “how long an absence is punished”, and the two MUST NOT be tuned independently.