The Tabletop Transform — ADR-126, ADR-127, ADR-128
The Tabletop Transform — ADR-126, ADR-127, ADR-128
Three rulings from Phase 3’s tabletop entity synchronisation workstream (2026-08-09, owner ruling). Building the drag-to-move loop end to end exercised three seams that had each been specified in isolation and never together, and every one of them turned out to have an answer that was implied rather than decided.
It is not an audit round and mints no
R*-F-*number. These were found by an implementer at the seam, the same route ADR-124 and ADR-125 came by. Each contradiction is stated in its own Context rather than filed, so it stays findable by the sentence that describes it.One of the three closes a live defect rather than an ambiguity. ADR-126’s context records a path that had been silently discarding every player’s movement since the drag gesture was written; the other two are decisions that were about to be made by accident.
ADR-126 — A Transform Commit Is Its Own Message, Because Movement Is Adjudicated by Nothing
Status: Accepted · Date: 2026-08-09 · Depends-on 050 (the lease this commit terminates), 092 (the cadence argument this applies to a fourth payload) · interacts with 046, 069
Context
The client committed the end of a drag as a SubmitIntent naming action: "move", with the
destination as two action arguments. No cartridge declares a move action. CoC7eRulesetEngine.Execute
switches on the action name and falls through to RulesetOutcome.Refuse($"'{Id}' does not handle action '{action}'"), so every commit was refused as an unhandled action, at the last step of a
path where everything before it worked: the lease was requested and granted, the ghost route was
computed and drawn, the token followed the pointer, and the position was discarded by the rules
engine on arrival. The comment beside the call asserted the opposite — that “the rules engine
adjudicates it, and the position it produces is the authoritative one” — which is the shape
frontend-guidelines.md warns about twice: a comment describing behaviour the code does not
have, because the next reader tests against the comment.
The deeper question the defect exposed is what an intent is. command.fbs says: a proposal the
rules engine adjudicates, whose result a replay must reproduce bit-for-bit. A position is none of
that. No ruleset varies it, no cartridge is consulted, it authors no core-stream event, and
Actor.MoveTo deliberately does not bump the ADR-046 premise revision — because a transform that
did would make every cartridge proposal stale within 50ms.
Decision
Movement travels on transform.fbs, an eighth schema, reaching RoomGrain.MoveActorAsync
through RoomHub.SubmitTransform → IntentIngress.SubmitTransformAsync. The ingress applies the
same four checks in the same order as every other client-to-server path: identity from the
connection, rate before parsing, decode treating the payload as hostile, authority last on the
decoded entity.
One message per gesture, never one per tick. This is not the client’s half of the 20Hz stream. During a drag the holder publishes ADR-050 clause 7’s coalesced advisory positions, which are non-authoritative by construction; this is the single commit at pointer-up.
Alternatives Considered and Why Rejected
- Implement a
moveaction in the cartridges. The smallest diff — the existing client call would have started working unchanged. Rejected because it makes movement ruleset-specific: a room whose cartridge failed to load could not move a token, and ADR-020 requires such a room to open in Archive Mode with history and replay intact. It also puts a position through cartridge resolution, where a signed cartridge running in-process with host privileges gets a say in where a miniature stands. - Fields on
lease.fbs. A lease is a claim on the transform path; this is a use of one. AReleaseverb that also carried a position would let a client move an entity by releasing a lease it never held, and the verb’s meaning would depend on which other fields happened to be set. The two also have different authority questions — may I drive this and this is where it ended up — and the server answers them separately. - Re-check the lease at commit time. Rejected, and this is the subtle one. A commit from a viewer whose lease expired mid-drag is a late arrival, not an attack, and refusing it would discard a move the player made while entitled to make it. Ownership is what stops a viewer moving what is not theirs, and ownership does not expire.
Consequences
- The client-to-server surface grows a fifth hub method, and
Q-082’s rate budget now covers a third message class. That is deliberate: a transform lands in the same mailbox an intent does, so a separate budget would let one connection spendQ-082twice against the thing it protects. - A refused commit is not reported to the player as a notification. The correction renders itself — the token eases back to where the server still says it is on the next tick — and a toast would state the same fact twice. A locally refused commit (malformed id, no transport) still surfaces, because that one is invisible otherwise.
Enforcement
TransformIngressTests drives the whole path against a real silo, including the refusal that
must move nothing. TransformCodecTests covers the hostile-buffer surface. The move-action
defect specifically is closed by APlayersCommitForTheirOwnTokenMovesIt, which asserts the
aggregate rather than the returned status.
ADR-127 — Elevation and Facing Are Authoritative Transform State, and They Ride the Tick Payload
Status: Accepted · Date: 2026-08-09 · Narrows 092 (which decided what rides the 20Hz packet, on a measurement this changes) · Depends-on 017 (the determinism contract that forbids float here) · interacts with 034, 023
Context
DisclosedEntity carried raw_x, raw_y and is_observer. The product needs a token to have a
height and a facing — a flying creature, a multi-level map, a miniature turned toward a door — and
neither existed anywhere in the stack. The question is not whether to carry them but how far
down: the whole authoritative stack is 2D by design. The grid, the fog mask, the interest set
and the Rust pathfinder all index an X/Y cell.
Decision
Elevation and facing are authoritative domain state: WorldPosition gains RawZ, Actor
gains a Facing, ActorTransform carries both, and they persist through Q-043’s transform
stream so a reactivated room restores them.
Facing is a fixed-point fraction of one turn (2^16 raw units is a revolution), not degrees
and not radians. ADR-017 forbids float on any path both hosts compute and this is one — the client
predicts orientation during a drag while the silo holds the authority. Radians would need a
transcendental constant no fixed-point width represents exactly; a turn fraction is exact because
the unit is the circle. It is normalised on construction, by a type, because one full turn
and no turn are the same direction and must be the same value.
Neither is a spatial index, and that is scope rather than omission. WorldPosition.Cell
remains two ordinates. An entity on a balcony is disclosed and occluded exactly as one beneath it,
and a facing narrows no line of sight — ADR-034’s observer is omnidirectional. Making either a
disclosure input is a geometry change and would start in rust-geometry-guidelines.md.
Alternatives Considered and Why Rejected
- Decouple them onto their own message, as ADR-092 did for attributes. The tempting symmetry, and wrong here. ADR-092’s test is cadence, not size: a stat changes when a stat changes, a fog chunk rides its own ladder, but a facing changes exactly when a position changes — they are one transform produced by one gesture. Splitting them would mean two packets per move arriving on different ticks, and a client interpolating a position toward one answer while rotating toward another.
- Carry them on the wire and in the renderer only, leaving the domain 2D. Smaller, and it fails on reactivation: a facing that does not reach the transform stream is correct for the whole session and gone after the first idle collection. Correct-then-silently-wrong is the worst available failure shape.
- A
Quaternionor a three-axis rotation. Rejected as unaffordable and unneeded: tokens stand on a floor, so one axis is the whole requirement, and threeint32s per entity per tick at 20Hz is a bandwidth decision taken for a capability nothing asks for.
Consequences
Q-056’s derivation moves and the cap does not. DisclosedEntity goes from 32 bytes to 40,
so a snapshot is 68 + 40n rather than 68 + 32n. Re-measured against the same room, seed and
500 viewers:
| measure | entities | was | now |
|---|---|---|---|
| p50 | 131 | 4,260 | 5,308 |
| p99 | 180 | 5,828 | 7,268 |
| §5.2 reference set | 200 | 6,468 | 8,068 |
Q-056 admits 817 entities rather than 1,021 — 4.1× the reference interest set rather than
5.1×. The cap itself is unmoved: it bounds blast radius, and nothing in this measurement argues
about that. The attributes-in-packet ratio ADR-092 cites as 128× is now ~103×, which weakens
the arithmetic and not the conclusion; the cadence argument never depended on the ratio’s size.
Architecture_Decision_Rulings_R8.md keeps its 2026-08-02 figures deliberately, as a
decision-time record, with a dated pointer to the live derivation.
The delta pass had to learn both fields, or a token that only climbed or only turned would be encoded once — when it entered the viewer’s area of interest — and never again, silently, for watching viewers only.
Enforcement
Q-056’s derivation is pinned by SnapshotSizeMeasurementTests and
PerViewerSnapshotCodecTests, which fail on a struct that grows without the registry moving with
it. WorldDeltaTransformFieldTests asserts that an elevation-only and a facing-only change each
reach the changed vector, and that a full-revolution facing produces no delta at all.
ActorTransformTests and TransformCommitFidelityTests cover the persistence round trip.
ADR-128 — Game-Master Command Authority Is a Registered Implementation, Not a Clause in the Fail-Closed Default
Status: Accepted · Date: 2026-08-09 ·
Resolves an open question recorded at OwnedActorsIntentAuthority and left open by 085 ·
Depends-on 042 (the typed scope surfaces this does not touch) · interacts with 050
Context
OwnedActorsIntentAuthority — the default IIntentAuthority — grants command authority by
ownership alone and records, in as many words, that it has “deliberately no game-master bypass”,
because “a game master who can command a player’s character without that having been decided is a
defect nobody notices until it is used.” It defers the question to the host’s membership model,
alongside what ADR-085 already records as open.
The tabletop model needs the bypass: the person running the game moves the miniatures. So the recorded objection had to be read precisely. It is an objection to an undecided bypass, not to the bypass.
Decision
A second implementation, GameMasterIntentAuthority, is registered in Program.cs. A game master
may command every entity in the room they are game master of, a player’s character included; a
player commands what they own; an unresolved caller (ViewerRole.Unknown) commands nothing, and
the role is checked before the owned set is consulted.
The fail-closed type stays in the tree, unweakened, and remains what a host gets by registering it instead.
Alternatives Considered and Why Rejected
- Add a
|| viewer.Role == GameMasterclause toOwnedActorsIntentAuthority. One line, and it is precisely what the type’s own comment forbids. The bypass would arrive as a detail inside a predicate rather than as a decision, and the sentence explaining why it should not exist would sit above the code doing it. Registering a different type puts the decision in a composition root where it is one visible line. - Leave it opt-in by configuration. Rejected because a fail-closed default nobody enables is a product that does not work, and “the GM cannot move an NPC” is the obviously incomplete half of the pair the original comment describes.
- An ordering comparison (
>= GameMaster) or a negative test (!= Player). Rejected for the reasonRoomGrain.ForceReleaseLeaseAsyncandSetOccludersAsyncalready record at their own role checks: the first silently admits any role added above it later, and the second admitsUnknown, which is the value an unresolved viewer carries and therefore the one caller that must never pass. Equality, always.
Consequences
This widens authority and not disclosure, and the two must not be conflated. §8.2’s rule that
“no role-based bypass exists anywhere” governs IViewerAuthority and the per-viewer filter, and is
untouched. A game master who may move a token they have not been disclosed is a coherent state and
is the one this composition produces.
A game master can now move a player’s character. That is the intended behaviour and it is also a real transfer of control, recorded here so it is attributable rather than discovered.
Enforcement
GameMasterIntentAuthorityTests covers all four arms, including the assertion that
OwnedActorsIntentAuthority still refuses a game master who owns nothing — the property that makes
“a second type rather than an edit” true rather than claimed.
TransformIngressTests.AGameMastersCommitForATokenTheyDoNotOwnMovesIt drives it end to end
against the authority Program.cs actually registers.