Descent VTT — Complete Records for ADR 063–071
Descent VTT — Complete Records for ADR 063–071
Date: 2026-08-01 Basis: the nine decisions produced by the three post-R2 backfill efforts (§14 verification and QA strategy, §9.7 Profile C interaction design, §7.5 cartridge evolution scenarios). Status of this document: the argument for these nine was written into the whitepaper body at the time each decision was made, and this file formats them as standalone records — this is not archaeology. The material for all four fields is complete and verifiable:
Context,Alternatives Considered and Why Rejected,Consequences, andEnforcementall come from the body text and need no backwards inference. That is the fundamental difference between these andArchitecture_Decision_Records_001-031.md.Writing convention (as originally recorded): 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.
The Origin and Shared Character of These Nine
| ADR | Source | The problem that triggered it |
|---|---|---|
| 063, 064, 065 | §14.2, §14.6, §14.5 | the whitepaper has 71 ADRs and dozens of normative sentences, and not one section explaining how any of them is proven to hold |
| 066, 067, 068 | §9.7 | §2.2 addresses only the cost of Profile C and says nothing about how a person plays; and two of the consequences are server behaviour and SDK contract, not layout |
| 069, 070, 071 | §7.5 | all three upcasting constraints of ADR-020 concern keeping old payloads deserializable, and none addresses what a payload means |
All three groups share one shape, worth writing out first: each is a case of “the original decision was right on its own axis, but that axis was not the only axis”. The §14 group found that “having rules” is not “having proof”; the §9.7 group found that “cost-feasible” is not “playable”; the §7.5 group found that “deserializable” is not “semantically stable”. This is the same failure mode recorded in Appendix A.1 of Architecture_Decision_Records_001-031.md — except that these three groups were found before the gap did damage.
ADR-063 — Verification Is Tiered by Available Determinism; Canvas Pixel Comparison Is Not a Release Gate
Status: Accepted · Date: 2026-08-01 · Links: interacts with 061, 057
Context
“Drive the client with Playwright and pixel-compare the canvas” is the obvious proposal, and it is the one a reader will re-propose every single year. It MUST be rejected as a release gate, but the reason MUST be written down, or it will come back again next year.
At the same time, a large part of that proposal is right — this client has a great deal of ordinary, deterministic DOM, and that is exactly where humans stop noticing regressions.
Decision
Verification is tiered by “the degree of determinism available”, not by “testing technology”:
- Pixel comparison of the canvas is excluded by default, permitted only under the conditions of T3.
- Playwright is adopted — as a DOM and interaction testing framework.
- T2 replaces pixel comparison with a render digest (§14.3): in test and debug builds, the Render Worker emits a normalized, ordered draw-submission digest for one frame — each pass’s pipeline identity, bind-group layout identities, instance count, stably sorted quantized instance transforms, the texture-atlas dirty-rect list, the currently presented mask tick, and the LOD tier selected for each asset. It is fully deterministic, vendor-independent, text-diffable, and consumes no GPU.
- T3 is honestly narrowed: a small, curated set of golden images, rendered on a version-pinned software adapter, compared perceptually rather than pixel by pixel, and run only when shader or material code changes. Its declared role is a change detector, not a correctness oracle.
Alternatives Considered and Why Rejected
Canvas pixel comparison as a release gate — three individually sufficient reasons:
- GPU rasterization is not reproducible across vendors, drivers, or driver revisions. Intermediate arithmetic precision,
fp16availability, raster coverage rounding, texture filtering and mip selection can all legitimately differ between NVIDIA/AMD/Apple/Intel, and between two driver versions from the same vendor. A pixel baseline must therefore either be recorded per(vendor × driver × OS × browser)— a matrix that decays faster than it can be maintained — or have its tolerance widened until it no longer detects the regressions it exists to detect. There is no stable middle setting; this is a property of the hardware, not of the tooling. - CI has no GPU. Real hosted runners resolve to a software adapter (Dawn/SwiftShader, lavapipe). A green light there proves the software backend renders, and says nothing at all about the Profile A path players actually run. A fake control is worse than no control, because it removes the pressure to build a real one — and this document’s own history (§9.5 Guardrail 3, §6.1) demonstrates exactly what happens when a control outlives its justification.
- The declared parity goal is semantic, not visual (ADR-061). Profile C draws baked top-down tiles (ADR-005) and has no 3D scene at all to compare against Profile A. A cross-Profile visual regression suite asserts a property this document has explicitly rejected, and it would fail correct builds.
Consequences (including negative)
- Positive: the render digest catches what a screenshot would catch only by luck, or not at all — a token never submitted at all, or submitted to the wrong pass or layer; §9.1.1 budgeting a dirty rect while a whole-atlas re-upload actually occurs (this is an upload behaviour claim, and a screenshot of the final frame is identical in both cases, so a pixel test is structurally blind to the one regression that budget exists to prevent); an instance count growing monotonically across frames (a leak signature, inexpressible in a single screenshot); a fog mask not advancing, or advancing at frame rate rather than the 20Hz+1 tick cadence; a video texture re-imported every frame degrading into a cached bind group, or the concurrent decode ceiling being exceeded.
- Negative (stated explicitly so nobody relies on it): the render digest does not catch whether a shader produces the expected colour. That is T3’s job, and T3 is a change detector.
- Negative (this is why it is architecture rather than a testing README): the digest requires a single recording seam through which all draw submissions pass. If draws are issued ad hoc from many call sites — the most natural way to write a renderer — the digest is not implementable, and retrofitting the seam means changing every call site at the point in the project where the renderer is least amenable to disturbance. The seam is a Phase 4 design constraint (§13).
- Negative: T3’s golden set MUST be explicitly labelled a change detector. It is written here precisely to stop it being cited later as a parity guarantee.
Enforcement
- §14.3: the render digest is emitted in test and debug builds; the single recording seam is a Phase 4 design constraint.
- §13 Phase 4: the seam is built at the same time as the renderer (the cost argument for retrofitting is above).
- T3 triggers only on shader/material changes, running on a version-pinned software adapter.
§11 Index Line
ADR-063: Verification is tiered by available determinism; pixel comparison of the canvas is rejected as a release gate and replaced by a deterministic draw-submission digest, with perceptual golden images demoted to a change detector on a pinned software adapter.
ADR-064 — A Compiled-Out Capability Veto & Fault-Injection Seam Is a First-Class Component
Status: Accepted · Date: 2026-08-01 · Links: Amends 027, 048
Context
This document’s most consequential claims are claims about scarcity and failure: every row of the Guardrail 7 matrix, the ordered degradation ladder of ADR-048, Guardrail 1’s device loss and terminal fallback to Profile B and then C, Guardrail 6’s four distinct wake states, ADR-050’s lease handback and staleAnchor, ADR-055’s expiry into ruleset-read-only, ADR-039’s isolated decode, and ADR-051’s “disabled with a reason” offline interface.
Not one of these conditions arises by itself in a test environment, and a matrix cell whose behaviour has never once executed is prose.
Guardrail 7’s “a blank cell blocks release” rule is already a substantial improvement — but it can still be satisfied by writing a sentence.
Decision
A test-only capability veto is therefore a first-class, built-in component rather than a mocking technique:
- A single injection point resolves every capability — WebGPU, cross-origin isolation/SAB, OPFS, WebCodecs and hardware decode sessions, WebRTC/SFU reachability, IndexedDB and persistent storage quota, worker-scope font rasterization — any of which can be forced absent or degraded, per automated test and per manual QA session.
- Resource budgets can be set arbitrarily small: the VRAM residency budget of §9.6.2, the single visibility memory budget of ADR-048, the plugin budgets of §9.5. A degradation ladder that takes four hours of play to reach its first step is a ladder tested once, by a customer.
- Faults are injectable on both sides:
device.lost, silo failover mid-tick, a projection behind the rebuild watermark (ADR-043), lease expiry and forced handback, a 503 cold-start wake, a refusedprotocolVersionhandshake (ADR-054), and an SFU that accepts the connection and then silently drops packets. - Guardrail 7 thereby becomes executable. Every row names the test that demonstrates its “capability absent behaviour”, and the release gate strengthens from “every cell has text” to “every cell has a passing test”. A cell without a test is equivalent to a blank — and that is the change that makes ADR-027 live up to its name.
Alternatives Considered and Why Rejected
- Faking capability absence per test with mocking: rejected. It scatters capability checks with no single source of truth, and cannot guarantee every check point is covered — “retrofitting an injection point means finding every capability check ever written” (§13 Phase 1 note).
- Keeping the “every cell has text” gate: rejected by this ADR’s Context. It can be satisfied with a sentence.
- Making the veto available in production (as a user-selectable degradation switch): explicitly rejected, see below.
Consequences (including negative)
Two consequences are deliberately accepted:
- The veto is compiled out of production builds, and this is not optional. Every path it touches is a degradation, so a runtime capability override surviving into production would hand an attacker a supported mechanism for disabling controls.
- The injection point is a permanent architectural seam, through which every capability check in the client MUST pass — a real constraint on how code is written, and the reason it belongs to architecture and appears in the §13 cross-cutting workstreams rather than in a testing appendix.
- Negative (a direct consequence of §13 Phase 1): the moment any code first asks “is WebGPU available”, the answer must come from this injection point. The cost of retrofitting is finding every capability check ever written; and until it exists, every row of Guardrail 7 is asserted only by prose.
- Negative: every cell of the matrix needs a test, an ongoing engineering cost that grows with the feature catalogue.
Enforcement
- Release gate: every cell has a passing test; no test is equivalent to a blank.
- Production builds: the veto is compiled out (assertable by inspecting build artefacts).
- §13 Phase 1: the injection point is a cross-cutting workstream, preceding the first capability check.
§11 Index Line
ADR-064: A compiled-out test-only capability veto and budget/fault injection seam is a first-class component; every Guardrail 7 cell and every degradation ladder must be demonstrated by a passing test, and a cell without one is treated as blank.
ADR-065 — Recorded Session Event Logs Are the Regression Corpus, Verified by the Production Digest
Status: Accepted · Date: 2026-08-01 · Links: Amends 056
Context
Because the system is event-sourced, its strongest test asset already exists and costs nothing to collect. Two independently unsolvable verification problems exist at the same time:
- Upcasters cannot be proven by unit tests. An upcaster’s correctness is a claim about events written years ago by a cartridge that may no longer be loadable, and it cannot possibly be proven by a unit test written against today’s event shapes.
- The diagnostics of §10.4 are exercised only during incidents. A diagnostic used only during an incident is a diagnostic discovered to be broken during an incident.
Decision
Recorded sessions — internal playtests, plus opt-in production incident exports from §10.4 — are replayed in CI on both sides of the boundary:
- Server side (T1, exact): the rules and projection layers replay the event stream and MUST produce identical projection state. This is simultaneously the only true test of every upcaster (§7.1, §7.5).
- Client side (T2/T5): the client replays the same event stream headlessly and MUST produce an identical applied-state digest (ADR-057). That digest already exists for operator diagnostics; reusing it as the CI oracle costs nothing extra and brings a property worth having deliberately — production diagnostics and the test oracle cannot drift, because they are the same artefact.
Corpus governance (because every golden corpus decays into a rubber stamp): this corpus shares a failure mode with geometry_parity.json (§5.3, ADR-056) — some change breaks an expectation, and the cheapest green light is to edit the expectation. Three rules:
- Adding cases requires no review and is actively encouraged. Friction should fall on changes, not on growth.
- Modifying or deleting an expectation MUST be a separate commit, reviewed by somebody other than the author, with a written justification — and an ADR if it encodes a decision. A golden file whose expectations can be edited in the same commit as the code that broke them asserts nothing; it merely records the most recent behaviour and calls it correct.
- The independent oracle (ADR-056) MUST NEVER be regenerated from the implementation. This is written explicitly because it is exactly the shortcut taken under deadline, it looks like maintenance, and it would silently convert the system’s only independent correctness reference into a tautology. The failure ADR-056 exists to detect — identical wrapping arithmetic producing the same wrong answer on both hosts — becomes undetectable again from that moment.
Alternatives Considered and Why Rejected
- Verifying upcasters with unit tests written against today’s event shapes: rejected (see Context 1). It is structurally incapable of testing the target.
- Building a separate independent oracle for CI: rejected by “reusing the ADR-057 digest costs nothing extra and brings a cannot-drift property”. Two oracles will drift, and the direction of the drift is necessarily that the test one becomes the more lenient.
- Allowing golden expectations to be updated in the same commit: explicitly rejected (see governance rule 2).
Consequences (including negative)
- Positive: production diagnostics and the test oracle are the same artefact, so they cannot drift.
- Positive: every upcaster has a structurally correct way of being tested.
- Negative (a direct consequence of §7.5.6, and it changes retention policy): a migration path without a pre-change archived event stream is structurally untested — which makes retaining those event streams a release requirement rather than an archival nicety. Every cartridge major still in service MUST have real event streams retained.
- Negative: the review process for expectation changes is an ongoing process cost, and it will be circumvented under time pressure — which is precisely why the rule is written as a triple (separate commit + review by another person + written justification) rather than merely “please be careful”.
Enforcement
Descent.ReplayTests(§3 project tree): recorded session event streams are replayed in CI.- The three corpus governance rules, enforced by the code review process.
- Retention policy: every cartridge major still in service needs archived event streams (a release requirement).
§11 Index Line
ADR-065: Recorded session event logs are the netcode and upcaster regression corpus, verified against the same ADR-057 digest used in production; golden expectations may only change in a separately reviewed commit, and the ADR-056 oracle may never be regenerated from the implementation.
ADR-066 — Profile C Splits by Viewport Role into C-Tactical and C-Companion
Status: Accepted · Date: 2026-08-01 · Links: Amends 005
Context
Everything this document says about Profile C is about cost — asset tier, draw calls, VRAM, the mandatory baked 2D requirement (ADR-005). Not one sentence explains how a person plays.
And that omission has been hiding a decision rather than deferring one: §2.2 labels Profile C “tablets/old devices”, while the roadmap, the FinOps model, and the Guardrail 7 matrix all quietly assume phones are covered.
A 6-inch phone and a 10-inch tablet are not the same client with different CSS, and the difference is not screen area — the difference is that one can display the tactical map and the character sheet simultaneously and the other cannot. Every design item below derives from that single fact.
Decision
Profile C splits by viewport role, not by device name:
- C-Tactical — landscape, effective viewport ≥ 8 inches (tablets, small laptops at the legacy asset tier). The map and a docked sheet panel coexist. Every player action is available.
- C-Companion — phones, and any viewport too small to hold two interfaces at once. The sheet comes first. The map still exists, but is demoted from a workspace to a locator: it answers “where is everyone, what can I reach”, not “let me compose a tactical scene”.
Declared unsupported (rather than degraded) on C-Companion: the Keeper/GM role, map authoring and Kitbashing (§6.3), the Rete.js node editor and Monaco macro authoring (§6.4), and whiteboard authoring (§9.3 — reading and following are supported). This follows the existing rule of §2.2: unverified features are disabled, not silently degraded. A GM opening the app on a phone is told this session can be joined but not run — before they start depending on it, not at the table.
Alternatives Considered and Why Rejected
- A single continuously responsive layout scaling down to 375px: explicitly rejected, and not on aesthetic grounds. A continuous layout forces every UI surface to be valid at every width simultaneously, which in practice means each is designed for the largest width and then hidden below some breakpoint. What gets hidden is decided component by component by whoever wrote it, so the composite result at 375px is an arbitrary subset of the game that nobody designed and nobody tested. Declaring two named roles means each has an owner, a layout, and its own Guardrail 7 column.
- Treating phones as “smaller tablets” and handling it with CSS: rejected by the core fact in Context.
Consequences (including negative)
- Positive: “does Profile C support phones” goes from a tacit assumption to a declared capability.
- Negative: the two roles each need a layout, an owner, and a Guardrail 7 column — a real doubling of design and test cost.
- Negative: the missing features on C-Companion are a product-level reduction and MUST be stated honestly in marketing and pricing, not glossed over with the phrase “mobile supported”.
- Negative (and this is why it is not a layout problem): the two consequences of this split — ADR-067 and ADR-068 — change server behaviour and the SDK contract respectively. They are not styling issues that can be handled later.
Enforcement
- §9.5 Guardrail 7: add a Viewport class & touch-only input row resolving to C-Tactical or C-Companion; every hover-only affordance declares a touch equivalent, and “it appears on hover” is equivalent to a blank.
- §9.7.2: free camera rotation and double-tap are not offered on C-Companion — by decision, not by limitation.
- ADR-064: that row needs a passing test.
§11 Index Line
ADR-066: Profile C splits by viewport role into C-Tactical and C-Companion, each with its own layout and Guardrail 7 column; a single continuously responsive layout down to phone width is rejected; the GM role and all authoring surfaces are declared unsupported on C-Companion rather than degraded.
ADR-067 — A Backgrounded Client Releases Every Lease and Ceases to Count as Room Liveness
Status: Accepted · Date: 2026-08-01 · Links: Amends 049, 050, 051
Context
Mobile operating systems freeze timers, stop requestAnimationFrame, and will close sockets held by background tabs. A player taking a phone call mid-combat is not a rare case; it happens in every session.
Ignoring it produces two mutually independent failures, and neither is a mobile bug — both are server-side consequences:
- A lease held by a frozen client suppresses that entity’s transform from every snapshot until expiry (§5.1.1). ADR-051 already rejected long-lived offline leases for exactly this reason — but it reasoned about “offline”, and a backgrounded phone is not offline: its socket is very likely still open. The entity freezes at its
staleAnchorfor the whole room, while the player who owns it is looking at a lock screen. - A backgrounded client MUST NOT count toward room activation liveness (ADR-049). Campaign branch autosuspend is gated on room liveness, and six phones left in the background, sitting in joined rooms all night, would keep a branch — and its cost — awake indefinitely with nobody playing.
Decision
visibilitychange → hiddenreleases every held lease immediately, without waiting for expiry.- Liveness requires a foregrounded client.
- Resume is a resync, not a reconnect. Returning to the foreground takes Guardrail 1’s path: request a per-viewer Full Snapshot (§8.2) before resuming delta application. Backgrounding is never treated as a data-loss event, and Guardrail 4’s offline Intent queue is not flushed on resume without the same premise checks any other reconnect performs.
Alternatives Considered and Why Rejected
- Relying on the lease’s natural expiry (2 seconds): rejected. The 2-second expiry window is designed for genuine connection loss, whereas a backgrounded client may persist for tens of minutes with its socket still open. For the entire room, that entity is stuck at
staleAnchorthroughout. - Treating backgrounding as offline and applying ADR-051: rejected. The premise ADR-051 reasons from (the connection is down) does not hold here. This is the most important point in this record: a correct rule applies to the scope of the premise it reasoned from, not to the scope its wording sounds like it covers.
- Using the existence of a connection as the liveness signal: rejected. This is the second time in this document that the liveness signal had to be narrowed from “a connection exists” to “somebody is actually there” — the first being ADR-049 narrowing it from “database traffic” to “room activation”.
Consequences (including negative)
- Positive: a backgrounded phone can no longer freeze an entity for a whole room, nor keep a campaign branch billing all night.
- Negative: a player returning to the foreground after a phone call will find the token they were dragging back at its starting point (
staleAnchor). This MUST be presented as a deliberate return with a brief explanation rather than a fault — the same presentation vocabulary as the expiry handling of §5.1.1 rule 6. - Negative: resume requires a full per-viewer snapshot, which is more expensive than a delta. Players switching foreground and background frequently generate extra load, which must be accounted for in §10.3.
- Negative: this rule spans three existing ADRs (049, 050, 051), and any future revision to any of them MUST re-check the interaction with this one.
Enforcement
- §9.5 Guardrail 7: an Application backgrounded / OS throttling row.
- §14.6: backgrounding is an injectable condition; that row needs a passing test (ADR-064).
- §10.4: the diagnostic envelope MUST distinguish “lease released due to backgrounding” from “lease expired due to connection loss”.
§11 Index Line
ADR-067: A backgrounded client releases every held lease immediately on visibilitychange and ceases to count as room-activation liveness; return to foreground is a per-viewer resync, never a data-loss event.
ADR-068 — UI Descriptors Declare Semantic Role and Priority so the Renderer Performs Profile-Specific Layout
Status: Accepted · Date: 2026-08-01 · Links: Amends 010, 027; parallel to 005
Context
A Call of Cthulhu 7E character sheet is a dense two-page form. Reflowing it into a single 375px column produces a document that is correct, complete, and unusable — every field present, nothing findable, and a roll four scroll-lengths away from the value it depends on.
And the platform cannot fix this per cartridge, which is exactly the architectural point. Sheets are written as JSON AST UI descriptors (§6.4, §9.5 Guardrail 5, §12), so a hand-tuned small-screen layout is not something the platform “could” write on behalf of a community cartridge — and a cartridge that renders unusably on a phone is indistinguishable from one that renders correctly, as seen from the platform side.
Decision
- The sheet is decomposed into task-shaped surfaces rather than scaled:
Roll,Combat,Inventory,Story. Which surface is presented by default is derived from the room’s current phase — openingCombatwhen a combat round starts — because the alternative is one player hunting through menus during their turn while five people wait. - UI descriptors declare the semantic role and priority of each field group, and the renderer performs profile-specific layout.
- Registration-time validation refuses a descriptor with no viable C-Companion rendering — the same rule and the same reason as “a bundle with no 2D bake is refused Profile C availability” (ADR-005): a profile is only real when the content pipeline is obliged to feed it.
- The sheet occludes the map, so the authoritative channel MUST NOT be occluded with it. On C-Companion the sheet is a full-height layer over the map, with a persistent situation strip: turn order, own HP and status, and unread event indicators. That strip is DOM, and it is fed by the Network Worker’s DOM-facing channel (ADR-053), never by the Render Worker. The reason is exactly Guardrail 1’s: a phone whose renderer is throttled, or whose canvas is not visible, publishes nothing, and a strip fed by it would sit there showing an HP total from four minutes ago while looking entirely live.
Alternatives Considered and Why Rejected
- Reflowing the desktop sheet into a single column: rejected (see Context). Correct, complete, unusable.
- Having the platform write small-screen layouts for community cartridges: structurally infeasible — descriptors are data, and the platform does not know which field group matters in which situation.
- Feeding the situation strip from the Render Worker’s ECS: rejected. This is ADR-053’s ownership rule applied to the case that makes it unavoidable, not merely to the case where it is correct.
Consequences (including negative)
- Positive: small-screen layout becomes the renderer’s job rather than every cartridge author’s.
- Negative: the SDK contract changes. Every existing descriptor lacks semantic role and priority annotations, so a migration is required; and the tightened registration-time validation will refuse descriptors that are legal today. This is a real breaking change for the creator ecosystem and MUST be handled through the deprecation window of §3.1.
- Negative: “a viable C-Companion rendering” needs a machine-decidable criterion, and that criterion is itself design work — too loose and it is ineffective, too tight and it refuses reasonable descriptors.
- Positive (a structural gain): ADR-068 and ADR-005 are two instances of the same rule. Writing them as the same rule rather than two independent requirements is how the future third instance gets recognized.
Enforcement
- Registration-time validation (§12): refuse descriptors with no viable C-Companion rendering.
- §9.5 Guardrail 7: the viewport row; the situation strip’s source channel is declared at code review (ADR-053’s value attribution rule).
- §14.4: plugin-facing SDK modules MUST NOT import the raw ECS or
GPUDevice.
§11 Index Line
ADR-068: UI descriptors declare semantic role and priority per field group so the renderer performs profile-specific layout; registration-time validation refuses a descriptor with no viable C-Companion rendering, on the same principle that refuses a bundle with no 2D bake.
ADR-069 — Domain Events Record Adjudicated Outcomes; the Rules Engine Is Never Re-Run Over History
Status: Accepted · Date: 2026-08-01 · Links: Amends 008, 020
Context
Replaying a 2027 combat under the 2029 rules engine produces the 2029 answer.
If the event stream records inputs awaiting adjudication — “spend 3 MP to cast X” — then every future rules change silently rewrites what happened at the table, and a campaign’s history becomes a function of the publisher’s release schedule.
Upcasting cannot save this, because the payload deserializes perfectly; what changed is the interpretation. All three constraints of ADR-020 concern keeping old payloads deserializable, and none touches this problem.
Decision
Domain events record adjudicated outcomes, with their inputs attached as evidence rather than as instructions.
SanityLostEvent records the resulting Sanity value and the fact that a bout was triggered; it does not record a roll awaiting re-evaluation.
The consequence is written out plainly, because it constrains every cartridge author: the system never re-adjudicates. A projection rebuild replays outcomes into the read model; it does not send decisions back through the rules engine.
This is precisely what makes ADR-022’s replays and export archives structurally immune to rules changes, and why the word “replay” in this document never means “re-simulation”.
Alternatives Considered and Why Rejected
- Recording inputs awaiting adjudication and re-adjudicating on replay: explicitly rejected (see Context). Campaign history would become a function of the publisher’s release schedule.
- Handling rules semantics changes with upcasters: explicitly rejected. An upcaster’s scope is payload shape, not interpretation. “The payload deserializes perfectly” is exactly why this failure is so hard to attribute.
- Freezing the rules engine version per campaign: not listed explicitly in §7.5, but partly realized by ADR-071’s “a campaign pins its cartridge major and opts in to upgrades” — the two are complementary: 069 protects history, 071 protects campaigns in progress.
Consequences (including negative)
- Positive: replay, export, and projection rebuild are all immune to rules changes, and the immunity is structural rather than procedural.
- Negative: this constrains event design for every cartridge author. An author who naturally writes “record what the player did, and let the rules decide the outcome” writes a cartridge that violates this rule, and the violation is entirely invisible until the rules change years later. This needs SDK-level guidance and a §12 registration-time check.
- Negative: event payloads grow — both outcome and evidence must be stored. The cold tiering of §7.4 therefore matters more.
- Positive (a semantic clarification): this ADR gives “replay” a single meaning throughout the document. Before it, whether §7.3’s replay, §14.5’s CI replay, and §10.4’s incident replay meant the same thing was unclear.
Enforcement
- §12 registration-time check: an event schema must be decidably recording outcomes rather than inputs awaiting adjudication (the criterion is SDK-level design work).
- §14.5: replaying recorded session event streams MUST produce identical projection state — which is simultaneously the test of this rule: if an event records an input awaiting adjudication, that test fails after a rules change.
§11 Index Line
ADR-069: Domain events record adjudicated outcomes with their inputs attached as evidence, never as instructions; the rules engine is never re-run over history, so projection rebuild replays outcomes and never re-adjudicates decisions.
ADR-070 — State Migrations Are a Distinct Artefact; Checkout Across a Cartridge Major Runs One, Previews, and Forks
Status: Accepted · Date: 2026-08-01 · Links: Amends 020, 047
Context
§7.3 lets a GM check out an earlier point on the timeline and continue playing from there. That is not replay: it produces live state, adjudicated forward by the currently loaded cartridge.
Checking out a tick from before v3.0.0 produces state containing sanity.bouts, and the loaded v3.0.0 cartridge has no rules for it.
There are three options, and two of them are wrong.
Decision
Event upcasters and state migrations are different artefacts doing different jobs. An upcaster maps old events to new event shapes; a state migration maps old entity state to new entity state — in this case, mapping four accumulated bouts to an equivalent set of Conditions. ADR-020 provides the second not at all, and that omission is entirely invisible until somebody time-travels. Therefore:
- A cartridge major that changes state shape declares a state migration alongside its upcasters.
- A checkout crossing that boundary runs it, previews the diff to the GM before committing, and forks to a new branch rather than mutating the existing timeline — consistent with ADR-047’s atomic swap saga and §7.3’s fork semantics.
- A checkout crossing a boundary with no declared state migration is refused, naming the blocking cartridge — the same failure mode and the same remedy as ADR-055’s per-room admission. Refusing with an actionable name beats opening a session whose numbers nobody can explain.
Alternatives Considered and Why Rejected
- Re-adjudicating under the v3 rules: rejected. It silently rewrites what happened, which §7.5.2 (ADR-069) explicitly forbids.
- Loading the v2 cartridge: rejected. §3.1 forbids it (that is an assembly loading boundary, not a deserialization boundary); and redirecting it to ADR-055’s compatibility environment would relocate the entire campaign rather than one checkout.
- Explicitly migrating state forward at checkout: the only defensible answer, and it requires a mechanism the platform did not then have — which is this ADR.
Consequences (including negative)
- Positive: time travel across a cartridge major has well-defined semantics, and the failure mode is “refused by name” rather than “a session whose numbers mean nothing”.
- Negative: cartridge authors gain one more obligatory artefact. Every major that changes state shape must write and test a state migration, and it is a different thing from an upcaster — which needs an explicit distinction in the SDK documentation, or authors will assume writing an upcaster was enough.
- Negative: “fork rather than mutate” means a cross-major checkout consumes one branch budget slot (§7.3, default 16). A GM time-travelling across majors frequently will hit the ceiling.
- Negative: the preview diff needs UI work, and that diff must be comprehensible to non-technical users — “four bouts become three Conditions” needs to be presented in game language rather than data language.
Enforcement
- At registration: a cartridge major changing state shape must declare a state migration (checked at publish).
- The checkout path: refuse and name the cartridge where none is declared.
- §14.5: cross-major checkout is a case class in the recorded corpus.
§11 Index Line
ADR-070: State migrations are a declarative artefact distinct from event upcasters; a timeline checkout crossing a cartridge-major boundary runs one, previews its diff, and forks rather than mutating — and is refused, naming the blocking cartridge, where none is declared.
ADR-071 — Attribute Removal Is a Two-Release Deprecation Against a Reverse-Dependency Index; a Campaign Pins Its Cartridge Major
Status: Accepted · Date: 2026-08-01 · Links: Amends 020, 055
Context
A cartridge that removes an attribute in a single release breaks a campaign mid-session, and the GM’s only warning was a changelog they did not read.
§3.1 already established a deprecation window for SDK majors; the same rule applies one layer down, and it was never stated.
A separate problem: IAttributeDefinition (§7.1) has no lifecycle — a definition either exists or it does not. Delete sanity.bouts, and a three-year-old character sheet renders an unlabelled sanity.bouts: 4, or the JSON-AST renderer fails on a descriptor referencing an unregistered key.
Decision
Four items, of which the third is the most consequential sentence in this section:
- v2.9 deprecates: the registry marks the attribute deprecated, sheets render it read-only with an explanation, new events stop writing it, and the GM sees a campaign-level notification naming the attribute and the release that will remove it.
- v3.0 removes the mechanism and leaves a tombstone:
Status = Retired,RetiredIn = 3.0.0, retainingDisplayNameand the type bounds. Retaining the definition and marking it retired is precisely the mechanism that keeps historical data readable while stopping it becoming writable — the same shape as ADR-055’s ruleset-read-only state, one layer down. - A campaign pins its cartridge major and chooses for itself when to upgrade. Without this sentence, a third-party publisher could break somebody’s paid campaign mid-story-arc on their own release schedule, and the platform would have no answer. ADR-055 already built the equivalent escape hatch for SDK majors; cartridge majors need the campaign-level analogue, with the GM choosing when to cross it.
- Removal is checked at publish time against a reverse-dependency index.
sanity.boutsmay be an input to another cartridge’s formula, a community macro (§6.4), or an SDK consumer the remover cannot see (§12). The registry MUST therefore be able to answer “what reads this key”, and publishing a removal that would break registered dependants is refused, naming those dependants. Without that index, “we removed an attribute” is a change whose blast radius nobody — the platform included — can know.
Alternatives Considered and Why Rejected
- Removal in a single release: explicitly rejected (see Context). It breaks mid-session.
- Deleting the registry entry: explicitly rejected. Historical sheets render unlabelled bare values, or the renderer fails on an unregistered key.
- The platform auto-upgrading all campaigns to the latest cartridge major: explicitly rejected by “a campaign pins its major”. That is the path by which a third-party publisher could break a paid campaign.
- Notifying via changelog only: explicitly rejected by “the GM’s only warning was a changelog they did not read”. The notification MUST be campaign-level and in-product.
Consequences (including negative)
- Positive: the blast radius of an attribute removal becomes a question the platform can answer.
- Negative: the reverse-dependency index is a new platform component that must be maintained, and it must index three heterogeneous classes of consumer (other cartridges’ formulas, community macros, SDK consumers). Macros are user-written code, and building a reliable static dependency index over them is real engineering work and cannot be complete — a macro that builds attribute keys by string concatenation escapes the index. This limitation MUST be stated, or the index will be treated as a guarantee.
- Negative: “a campaign pins its major” means the platform will serve multiple cartridge majors simultaneously, at the same cost as ADR-055’s compatibility environment — which must enter the cost model of §10.2.
- Negative: a two-release deprecation window slows cartridge authors’ iteration, a real friction on the creator ecosystem, and a deliberate one.
Enforcement
- At publish: check removals against the reverse-dependency index; refuse and name dependants if registered ones would break.
IAttributeDefinitiongains aStatuslifecycle (Active/Deprecated/Retired) andRetiredIn.- Campaign-level cartridge major pinning with an explicit upgrade choice.
- §14.5: recorded event streams spanning a deprecation are a corpus case.
§11 Index Line
ADR-071: Attribute definitions carry a retired tombstone state; attribute removal is a two-release deprecation; a campaign pins its cartridge major and opts in to upgrades; removal is refused at publish time against a reverse-dependency index over formulas, macros and SDK consumers.
Appendix B — Three Interactions Among These Nine
Three things visible only once they were written as standalone records, noted here.
B.1 ADR-069 and ADR-071 Are Complementary, Not Overlapping
Both concern “a rules change must not harm existing campaigns”, but they protect different things, and neither alone is sufficient:
- ADR-069 protects history: what already happened is not re-adjudicated.
- ADR-071 protects campaigns in progress: the rules do not change mid-story-arc.
A system with only 069 has stable history but active campaigns broken mid-session; a system with only 071 has unbroken campaigns, but the moment a GM chooses to upgrade, the entire history is reinterpreted.
B.2 ADR-063, 064, and 065 Form a Closed Loop, and Losing Any One Degrades It
- 064 creates the conditions (capability veto, budget injection, fault injection).
- 063 provides observation of the rendering path (the render digest, because pixel comparison is unusable).
- 065 provides the inputs and the oracle (recorded event streams + the ADR-057 digest).
Without 064, 063 and 065 can only test the happy path — and this document’s most consequential claims are all about failure. Without 063, the rendering path is left with only two options: flaky or blind. Without 065, upcasters are structurally untestable and the diagnostics are exercised only during incidents.
B.3 ADR-068 and ADR-005 Are Two Instances of the Same Rule
“A profile is only real when the content pipeline is obliged to feed it.”
- ADR-005: a bundle with no 2D bake is unavailable on Profile C.
- ADR-068: a descriptor with no viable C-Companion rendering is refused at registration.
Writing them as two instances of the same rule rather than two independent requirements is how the future third instance gets recognized — and given this document’s history (see Appendix A.1 of Architecture_Decision_Records_001-031.md), a third instance is close to certain to appear.