# Test Scenarios — Easy-Multiplayer v2

This is the **English scenario catalog** — the test spec for the v2 redesign. Each scenario is written in English first, then translated to executable JS against the test harness (see `GOALS.md` A1–A3). Writing scenarios in English first is mandatory for novel behaviors so edge cases surface before code commits to a behavior.

## Status Legend

- `[ ]` Drafted (English only)
- `[~]` Open questions — needs design decision before translation
- `[T]` Translated to executable test
- `[P]` Passing

## Scenario Template

```
S-NNN-MM  <short title>
Category: <category name>
Phase: <A/B/C/M>  Goal: <e.g. B5>
Preconditions:
  - <state of harness at scenario start>
Actions:
  1. <step>
  2. <step>
Expected:
  - <observable outcome>
  - <observable outcome>
Open questions:
  - <question, or "none">
Status: [ ] / [~] / [T] / [P]
```

ID convention: `S-CCC-MM` where `CCC` is the category number from the list below, `MM` is a sequence within the category.

---

## Categories

1. Single-peer determinism
2. Basic two-peer rollback
3. Sparse input correctness
4. Silence semantics
5. Attendance & liveness
6. Context-aware intent construction
7. Passive participation
8. Acceptance window edges
9. Grace window relay
10. Hash window exchange
11. Uncertainty-aware desync — false-positive avoidance
12. Uncertainty-aware desync — true-positive detection
13. Disconnect as simulation event
14. Disconnect agreement under partition
15. Severe desync recovery
16. Lagging peer wake
17. Authority tie-break
18. Predicate context freezing
19. Predicate evaluator purity
20. Network conditions sweep
21. Joins / leaves mid-game
22. State serialization round-trip
23. Memory finalization
24. Massive passive participation
25. Partition heal
26. Transport-agnostic behavior
27. Server-authoritative mode (future)
28. Bus-based rollback smoothness (Phase C+)

---

## Scenarios

*Population is incremental. Goal A4 populates categories 1–5 with the scenarios below. Each subsequent Phase B goal adds the scenarios that drove its implementation (Meta-Goal M2).*

*Shared assumptions unless a scenario says otherwise: tick rate = 20 ticks/s, so **1 tick = 50 ms** of virtual time; peers run on `MemoryTransport` over the deterministic `VirtualClock`; "advance to tick N" means advance the virtual clock to `N * 50 ms`. Hashes are compared with `expectConverged`; query results with `expectQueryResult`.*

---

### Category 1 — Single-peer determinism

The floor: if one isolated peer is not bit-deterministic, nothing above it can converge. No transport involved.

```
S-001-01  Identical run from identical seed + input log
Category: Single-peer determinism
Phase: A/B  Goal: A2/C5
Preconditions:
  - 1 peer, seeded RNG, a fixed scripted input log for ticks 0..200
Actions:
  1. Run the simulation 0..200, record the state hash at every tick
  2. Reconstruct a fresh peer with the same seed and the same input log
  3. Run again 0..200, record hashes
Expected:
  - The two hash sequences are element-for-element identical
Open questions: none
Status: [ ]
```

```
S-001-02  All in-simulation randomness flows through the seeded RNG
Category: Single-peer determinism
Phase: B  Goal: C5
Preconditions:
  - 1 peer; game tick calls random() (the engine-provided RNG) twice per tick
Actions:
  1. Run 0..50 with seed S, record hashes
  2. Run 0..50 with seed S again
  3. Run 0..50 with seed S+1
Expected:
  - Runs 1 and 2 are identical
  - Run 3 differs (proves the RNG actually drives state and is seed-controlled)
Open questions:
  - Do we forbid raw Math.random()/Date.now() in tick code, or detect-and-warn, or silently allow (and accept desync)? (see KNOWN_ISSUES "determinism enforcement")
Status: [~]
```

```
S-001-03  State hash is canonical regardless of object key insertion order
Category: Single-peer determinism
Phase: B  Goal: C5
Preconditions:
  - 1 peer; two state objects that are deep-equal but whose keys were inserted in different orders
Actions:
  1. Hash state built as {a:1, b:2}
  2. Hash state built as {b:2, a:1}
Expected:
  - Both hashes are equal
Open questions:
  - Must the hasher canonicalize key order (sort keys) before hashing, or is insertion order guaranteed stable and therefore part of the contract? Cross-browser JS object order is stable for string keys but NOT for integer-like keys.
Status: [~]
```

```
S-001-04  Snapshot round-trip is lossless
Category: Single-peer determinism
Phase: B  Goal: B9/B10
Preconditions:
  - 1 peer at tick 100 with non-trivial state (nested objects, arrays, a Map if supported)
Actions:
  1. ExportState() -> S
  2. Mutate the live state by ticking to tick 110
  3. ImportState(S); hash at the restored point
Expected:
  - Hash after ImportState(S) equals the hash recorded at tick 100 before step 2
Open questions:
  - Is dev-defined serialization (manageState) required for any state that isn't plain-JSON, or does the engine deep-clone structurally? (ties to product Q "state serialization dev-defined or auto")
Status: [~]
```

```
S-001-05  Re-simulation from a snapshot reproduces the original forward history
Category: Single-peer determinism
Phase: B  Goal: B1
Preconditions:
  - 1 peer; input log for ticks 100..120; snapshot taken at tick 100
Actions:
  1. Simulate 100..120 forward, record hashes (the "original" history)
  2. ImportState(snapshot@100); re-simulate 100..120 with the same input log
Expected:
  - Re-simulated hash sequence equals the original (this is the property rollback depends on)
Open questions: none
Status: [ ]
```

```
S-001-06  Tick advance is decoupled from wall-clock pacing
Category: Single-peer determinism
Phase: B  Goal: B1
Preconditions:
  - 1 peer; identical input log
Actions:
  1. Advance the virtual clock in one 1000 ms jump
  2. Fresh peer: advance the clock in 20 separate 50 ms steps
Expected:
  - Same final tick number and identical final hash (logical ticks, not real-time pacing, drive simulation)
Open questions: none
Status: [ ]
```

```
S-001-07  Emitted events are deterministic and replay identically on re-sim
Category: Single-peer determinism
Phase: B  Goal: B1
Preconditions:
  - 1 peer whose tick emits an event when a counter crosses a threshold
Actions:
  1. Simulate 0..60, record the (tick, type, data) of every emitted event
  2. ImportState(snapshot@0); re-simulate 0..60, record events again
Expected:
  - Identical event lists (events are a deterministic function of state, not a side channel)
Open questions:
  - On rollback, are events from the discarded timeline retracted/un-emitted, or do listeners see both the rolled-back and re-simulated emissions? (event semantics under rollback)
Status: [~]
```

```
S-001-08  deltaTime is fixed per tick regardless of real tick timing
Category: Single-peer determinism
Phase: A  Goal: A3
Preconditions:
  - 1 peer; tick records the deltaTime it was given each tick
Actions:
  1. Advance the clock irregularly (10 ms, then 90 ms, then 200 ms)
Expected:
  - Every tick that fired saw deltaTime == 50 ms (fixed timestep), never the wall-clock delta
Open questions: none
Status: [ ]
```

```
S-001-09  No hidden nondeterminism leaks across two engine instances in one process
Category: Single-peer determinism
Phase: B  Goal: C5
Preconditions:
  - 2 engine instances in the same process, same seed, same input log, run interleaved
Actions:
  1. Step instance X one tick, then instance Y one tick, alternating, 0..40
Expected:
  - Both reach identical final hashes (no shared mutable global state between instances)
Open questions:
  - Does any module-level singleton (RNG, id counter, event bus) bleed between instances? (relates to KNOWN_ISSUES "global state dependence")
Status: [~]
```

---

### Category 2 — Basic two-peer rollback

The semantic-rollback core: rollback happens because a query *result* changed, never merely because a raw input differed.

```
S-002-01  Late input within acceptance window triggers rollback when a query result changes
Category: Basic two-peer rollback
Phase: B  Goal: B1+B5
Preconditions:
  - 2 peers (A, B), no latency, both at tick 100
  - Game logic queries: query(A, ctx, (i, ctx) => i.jump)
  - B has been predicting A's intent as {jump:false}
Actions:
  1. Advance both peers to tick 105
  2. A confirms intent at tick 102 as {jump:true} (within the acceptance window)
  3. Deliver and process
Expected:
  - B rolls back to tick 102 and re-simulates 102..105
  - B's query result at tick 102 changes false -> true
  - expectConverged(A, B) at tick 105
Open questions: none
Status: [ ]
```

```
S-002-02  Late input that changes NO query result triggers NO rollback
Category: Basic two-peer rollback
Phase: B  Goal: B4+B1
Preconditions:
  - 2 peers (A, B) at tick 105; logic only queries i.jump
  - B predicted A as {jump:false, sprint:false}
Actions:
  1. A confirms intent at tick 102 as {jump:false, sprint:true} (sprint is never queried)
  2. Deliver and process
Expected:
  - B records the corrected intent but performs NO rollback (no queried predicate flips)
  - Hash sequence on B is unchanged from before the message arrived
Open questions: none
Status: [ ]
```

```
S-002-03  Both peers converge to an identical hash after a rollback
Category: Basic two-peer rollback
Phase: B  Goal: B1+B5
Preconditions:
  - As S-002-01
Actions:
  1. Run S-002-01 through tick 130 (well past the rollback)
Expected:
  - expectConverged(A, B) at every checkpoint tick from 105..130
Open questions: none
Status: [ ]
```

```
S-002-04  Simultaneous late inputs from both peers
Category: Basic two-peer rollback
Phase: B  Goal: B1
Preconditions:
  - 2 peers at tick 110; each queries the other's jump
  - Each predicted the other as {jump:false}
Actions:
  1. A confirms {jump:true}@103; B confirms {jump:true}@104; both delivered together
Expected:
  - Each peer rolls back to the earlier of the two affected ticks (103) and re-simulates
  - expectConverged(A, B) at tick 110
Open questions:
  - When two corrections land in one processing batch, is rollback computed once to the earliest affected tick, or applied per-message (rollback to 104 then again to 103)? Batching avoids redundant re-sim.
Status: [~]
```

```
S-002-05  Input arriving after the acceptance window has closed is rejected as a direct correction
Category: Basic two-peer rollback
Phase: B  Goal: B6
Preconditions:
  - 2 peers; acceptanceWindow = 6 ticks; B currently at tick 120
Actions:
  1. A confirms intent for tick 100 (20 ticks old, well outside the window) and it reaches B
Expected:
  - B does NOT directly apply it as an accepted correction (too old)
  - It may still be eligible via the grace/relay path (see Category 9), but NOT via direct acceptance
Open questions:
  - Is the acceptance window measured against the receiver's current tick, the sender's tick stamp, or wall-clock arrival time? These diverge under latency.
Status: [~]
```

```
S-002-06  Correct prediction produces no rollback
Category: Basic two-peer rollback
Phase: B  Goal: B1
Preconditions:
  - 2 peers at tick 108; B predicted A as {jump:true}@102
Actions:
  1. A confirms intent @102 as exactly {jump:true}
Expected:
  - B performs no rollback; the confirmation only finalizes the already-correct tick
Open questions: none
Status: [ ]
```

```
S-002-07  Wrong prediction on an unqueried field never causes rollback even when later queried fields agree
Category: Basic two-peer rollback
Phase: B  Goal: B4
Preconditions:
  - 2 peers; logic queries only i.jump; B predicted A as {jump:false, aim:{x:0}}
Actions:
  1. A confirms @102 {jump:false, aim:{x:5}} (aim differs, jump same)
Expected:
  - No rollback (the only queried predicate, jump, did not flip)
Open questions:
  - If the game later starts querying i.aim at a tick AFTER the correction was finalized, is the past correction retro-applied or lost? (query set is not static over time)
Status: [~]
```

```
S-002-08  Rollback depth equals the distance to the earliest changed tick, no further
Category: Basic two-peer rollback
Phase: B  Goal: B1
Preconditions:
  - 2 peers at tick 140; B has clean confirmed history through tick 129
Actions:
  1. A confirms a query-flipping intent @130
Expected:
  - B re-simulates exactly 130..140 (11 ticks), not from an earlier point
  - Ticks <130 are untouched (their hashes are unchanged)
Open questions: none
Status: [ ]
```

```
S-002-09  Out-of-order arrival of two corrections from the same peer
Category: Basic two-peer rollback
Phase: B  Goal: B1
Preconditions:
  - 2 peers at tick 115; B queries A.jump
Actions:
  1. A's {jump:true}@110 and {jump:false}@112 are sent, but @112 is DELIVERED FIRST, then @110
Expected:
  - After both are processed, B's reconstructed stream is jump:true on [110,112) and jump:false from 112 on
  - Final hash is independent of delivery order; expectConverged(A, B) at 115
Open questions: none
Status: [ ]
```

---

### Category 3 — Sparse input correctness

Inputs are sent only when they change; the receiver reconstructs the continuous per-participant input stream.

```
S-003-01  Receiver reconstructs a continuous stream from sparse changes
Category: Sparse input correctness
Phase: B  Goal: B1
Preconditions:
  - 2 peers; A queries B.move
Actions:
  1. B sends move=LEFT @100, then move=RIGHT @140 (nothing in between)
Expected:
  - A treats B.move as LEFT for ticks 100..139 and RIGHT from 140 on (silence = hold last value)
Open questions: none
Status: [P]
```

```
S-003-02  Silence preserves the last input indefinitely
Category: Sparse input correctness
Phase: B  Goal: B1
Preconditions:
  - 2 peers; B sent move=UP @50 and nothing since
Actions:
  1. Advance to tick 5000 with no further messages from B (attendance continue)
Expected:
  - A still simulates B.move == UP at tick 5000
Open questions: none
Status: [P]
```

```
S-003-03  Explicit transition to passive (intent = null)
Category: Sparse input correctness
Phase: B  Goal: B1+B3
Preconditions:
  - 2 peers; B is active with move=DOWN @200
Actions:
  1. B sends intent=null @260 (explicit passive transition)
Expected:
  - From tick 260, A simulates B as having no active input (passive), distinct from "holding DOWN"
Open questions:
  - RESOLVED (B3, DECISIONS #25b): "passive" is EXCLUDED from input-bearing logic, NOT a defined neutral input. The reconstructed value is literally `null` (distinct from any intent object such as `{move:'DOWN'}`), so iterators of active inputs skip it.
Status: [P]   (local-intent-harness.test.js "S-003-03"; selftest-b3.mjs — reconstructed null@80 ≠ held {move:DOWN}@30)
```

```
S-003-04  Duplicate (retransmitted) intent is idempotent
Category: Sparse input correctness
Phase: B  Goal: B1
Preconditions:
  - 2 peers; A queries B.jump; B sends {jump:true}@100
Actions:
  1. The same {jump:true}@100 message is delivered to A twice
Expected:
  - No double-application, no spurious rollback on the second copy; hashes unchanged by the duplicate
Open questions: none
Status: [P]
```

```
S-003-05  First-ever input establishes the participant's baseline
Category: Sparse input correctness
Phase: B  Goal: B1+B3
Preconditions:
  - 2 peers; B has sent nothing yet; A must simulate B from tick 0
Actions:
  1. B's first message is {jump:true}@30
Expected:
  - Before tick 30, A simulates B as PASSIVE (reconstructed value literally null — not a participant those ticks); from 30, jump:true
Open questions:
  - RESOLVED (B3, DECISIONS #25c): the canonical default for a participant with no history is null (PASSIVE), not a game-declared neutral. A never-heard-from participant is passive everywhere until its first intent arrives; a game MAY override defaultIntent but all peers must agree. Resolves KNOWN_ISSUES #13.
Status: [P]   (local-intent-harness.test.js "permanently-passive" reconstructs C as null@100; selftest-b3.mjs)
```

```
S-003-06  Per-tick changes degrade gracefully to a near-dense stream
Category: Sparse input correctness
Phase: B  Goal: B1
Preconditions:
  - 2 peers; B's input changes every single tick for 100..160
Actions:
  1. B sends 60 consecutive change messages
Expected:
  - A reconstructs the exact stream; correctness holds even when "sparse" becomes dense (no special-casing breaks)
Open questions: none
Status: [P]
```

```
S-003-07  Input tick delay buffers an intent stamped for a future tick
Category: Sparse input correctness
Phase: B  Goal: B1+B3
Preconditions:
  - 2 peers; inputDelay = 4 ticks; B at tick 100 samples an input meant to take effect at 104
Actions:
  1. B sends {jump:true}@104 while its own simulation is at tick 100
Expected:
  - A buffers and applies it at tick 104, with NO rollback if it arrives before A simulates 104
Open questions:
  - How do inputDelay and the acceptance window compose? An input delivered within the delay buffer needs no rollback; one delivered after needs one. Are both measured in the same tick space?
Status: [~]
```

```
S-003-08  Reordered change messages reconstruct correctly by tick stamp
Category: Sparse input correctness
Phase: B  Goal: B1
Preconditions:
  - 2 peers; A queries B.weapon
Actions:
  1. B sends weapon=A@100, weapon=B@110, weapon=C@120, delivered in order C,A,B
Expected:
  - A's reconstructed stream is A on [100,110), B on [110,120), C from 120 (tick stamp wins over arrival order)
Open questions: none
Status: [P]
```

```
S-003-09  A redundant "change" equal to the current value is a no-op
Category: Sparse input correctness
Phase: B  Goal: B1
Preconditions:
  - 2 peers; B.move == LEFT (sent @100)
Actions:
  1. B sends move=LEFT @150 (same value)
Expected:
  - No rollback, no observable state change; treated as redundant
Open questions:
  - RESOLVED (B1): the sender (`SparseInputEncoder`) suppresses equal-value changes for bandwidth, and the decoder treats an equal-to-held change as a no-op. Suppression is NEVER used as liveness — liveness is the separate heartbeat channel (B2), so it cannot starve presence.
Status: [P]
```

```
S-003-10  Intent meaning is bound at sample time, immune to rollback reinterpretation
Category: Sparse input correctness
Phase: B  Goal: B3
Preconditions:
  - 1 peer holding hardware button A; local game state switches scene 'game' -> 'dialog' at tick 50
Actions:
  1. getLocalInputs(localGameState) maps button A to {jump:true} while scene=='game', {confirm:true} while scene=='dialog'
  2. Button A is held continuously across the scene switch
Expected:
  - The shipped/reconstructed intent is {jump:true} for ticks < 50 and {confirm:true} from 50 on — the SAME hardware hold produces different semantic intents, and a later rollback re-reading the stream cannot reinterpret an already-stamped {jump} as {confirm} (meaning was frozen at sample time)
  - Sparse: exactly two change packets (jump@first-active, confirm@50)
Open questions:
  - RESOLVED (B3, DECISIONS #25a): meaning is bound when getLocalInputs runs locally, not derived from raw hardware at apply time, so rollback replays the stored semantic intent verbatim.
Status: [P]   (local-intent.test.js "context binding"; local-intent-harness.test.js "context binding" — jump@20, confirm@70, messagesSent===2; selftest-b3.mjs)
```

---

### Category 4 — Silence semantics

The subtle category: silence means "unchanged," never "gone." Only the liveness layer (Category 5) decides "gone."

```
S-004-01  A silent active peer stays active and present
Category: Silence semantics
Phase: B  Goal: B1+B2
Preconditions:
  - 2 peers; B active with a held input; attendance on
Actions:
  1. Advance 30 s with zero input messages from B (attendance continue)
Expected:
  - A still lists B as an active participant and simulates its held input
Open questions: none
Status: [ ]
```

```
S-004-02  Silence neither advances nor resets a peer's input
Category: Silence semantics
Phase: B  Goal: B1
Preconditions:
  - 2 peers; B sent {throttle:0.7}@100
Actions:
  1. Advance to tick 1000 with no B messages
Expected:
  - B.throttle is exactly 0.7 throughout (no decay-to-zero, no drift)
Open questions: none
Status: [P]
```

```
S-004-03  At the input layer, "silent because unchanged" is indistinguishable from "silent because gone"
Category: Silence semantics
Phase: B  Goal: B2
Preconditions:
  - 2 peers; B has sent no input for 40 ticks
Actions:
  1. Inspect A's input-layer view of B with attendance still arriving
  2. Inspect A's input-layer view of B with attendance also stopped
Expected:
  - The INPUT layer's reconstructed value for B is identical in both cases (held last value)
  - Only the LIVENESS layer differs (case 2 will eventually fire onPeerLeft)
Open questions:
  - During the attendance-timeout window a peer is "maybe gone": does A keep optimistically simulating its held input until the disconnect event resolves, and roll back the speculative disconnect if attendance resume? (speculative-disconnect rollback)
Status: [~]
```

```
S-004-04  A passive participant produces no intents ever and is simulated as passive
Category: Silence semantics
Phase: B  Goal: B1+B3
Preconditions:
  - 3 peers; C joined as passive (spectator), never sends an intent
Actions:
  1. Advance 60 s
Expected:
  - A and B simulate C as passive the entire time; C's permanent silence is never read as a disconnect
Open questions: none
Status: [P]   (local-intent-harness.test.js "permanently-passive" — C ships 0 packets over 10 s, A reconstructs C as null; local-intent.test.js "200 ticks → 0 packets"; selftest-b3.mjs)
```

```
S-004-05  Input silence with healthy attendance never triggers disconnect
Category: Silence semantics
Phase: B  Goal: B2
Preconditions:
  - 2 peers; attendanceInterval well under the liveness timeout
Actions:
  1. B sends attendance but zero inputs for 5 minutes of virtual time
Expected:
  - No onPeerLeft for B; B remains an active (if silent) participant
Open questions: none
Status: [P]   (tests/liveness-harness.test.js "S-004-05"; selftest-b2.mjs — app-flood-with-stalled-attendance still times out, the converse direction)
```

```
S-004-06  Long silence followed by a change applies the change at its stamped tick
Category: Silence semantics
Phase: B  Goal: B1
Preconditions:
  - 2 peers; B sent {fire:false}@100, silent since
Actions:
  1. At virtual tick 900, B sends {fire:true}@880 (within acceptance window of receiver's current tick)
Expected:
  - A holds fire:false for 100..879 and applies fire:true from 880, rolling back only if a query result flips in [880, now]
Open questions: none
Status: [P]
```

```
S-004-07  Silence under partition is invisible to the input layer
Category: Silence semantics
Phase: B  Goal: B2
Preconditions:
  - 2 peers; link A<->B partitioned at tick 200
Actions:
  1. B keeps changing inputs locally during the partition (A receives nothing)
  2. Advance to tick 260
Expected:
  - A's input layer simply holds B's last pre-partition value (cannot tell partition from quiet)
  - A's liveness layer is the only place the partition surfaces (B stops attending to A) — see S-005-05
Open questions: none
Status: [ ]
```

```
S-004-08  A new joiner cannot learn a long-silent peer's input from silence; it must come from bootstrap
Category: Silence semantics
Phase: B  Goal: B10
Preconditions:
  - Peers A, B established; B last changed input @50 (long ago); C joins at tick 400
Actions:
  1. C bootstraps; B sends nothing new (it is silent — unchanged)
Expected:
  - C learns B's current input from the bootstrap payload (DECISIONS #18 "current per-participant intent"), NOT by waiting for a B message that will never come
Open questions:
  - At what tick is the bootstrapped "current intent" stamped for the joiner's reconstruction — B's original change tick (50), or the grace-window edge of the bootstrap snapshot? They affect whether C can later accept an in-window correction for B.
Status: [~]
```

---

### Category 5 — Attendance & liveness

Liveness is transport-internal and message-independent (TRANSPORT_SPEC #5). These scenarios pin down how the *simulation* reacts to join/leave events.

```
S-005-01  Attendance maintain presence with zero inputs
Category: Attendance & liveness
Phase: B  Goal: B2
Preconditions:
  - 2 peers; attendanceInterval = 500 ms; liveness timeout = 2 s
Actions:
  1. B emits only attendance for 20 s
Expected:
  - A never fires onPeerLeft for B; getPeers() on A always contains B
Open questions: none
Status: [P]   (heartbeat-liveness.test.js "S-005-01"; liveness-harness.test.js; selftest-b2.mjs)
```

```
S-005-02  Missing attendance beyond the timeout fire a disconnect
Category: Attendance & liveness
Phase: B  Goal: B2+B7
Preconditions:
  - 2 peers; liveness timeout = 2 s (40 ticks)
Actions:
  1. B stops all transport activity at tick 100
  2. Advance past the timeout
Expected:
  - A fires onPeerLeft(B); B becomes a disconnect-as-simulation-event (Category 13)
Open questions: none
Status: [P]   (B2 liveness portion: heartbeat-liveness.test.js "S-005-02" + liveness-harness.test.js partition test — onPeerLeft fires once, only after the timeout, never immediately. The "disconnect-as-simulation-event" half is Goal B7.)
```

```
S-005-03  Attendance resuming within the timeout keep the peer present
Category: Attendance & liveness
Phase: B  Goal: B2
Preconditions:
  - 2 peers; liveness timeout = 2 s
Actions:
  1. B's attendance gap for 1.5 s then resume
Expected:
  - No onPeerLeft fired; B continuously present
Open questions: none
Status: [P]   (heartbeat-liveness.test.js "S-005-03"; liveness-harness.test.js partition-then-heal; selftest-b2.mjs)
```

```
S-005-04  Liveness is independent of input flow
Category: Attendance & liveness
Phase: B  Goal: B2
Preconditions:
  - 2 peers
Actions:
  1. B floods inputs but its attendance mechanism is (artificially) stalled past the timeout
Expected:
  - Per spec, liveness is driven by the transport's own mechanism; if that mechanism is the attendance and it stalls, B is declared gone EVEN THOUGH inputs arrive — OR inputs themselves count as liveness evidence
Open questions:
  - RESOLVED (B2): liveness is STRICTLY the dedicated heartbeat channel. App messages are consumed for delivery only and never call `noteHeartbeat`; a peer whose heartbeat stall is declared gone even while flooding app traffic (the first branch above). The converse is NOT true — app traffic cannot keep a peer alive. See DECISIONS #24c and tests/liveness-harness.test.js "S-004-05".
Status: [P]
```

```
S-005-05  Two peers disagree about a third's liveness under partition
Category: Attendance & liveness
Phase: B/C  Goal: B7/C
Preconditions:
  - 3 peers A, B, C fully connected; partition only the C<->A link at tick 100
Actions:
  1. C keeps attending to B but cannot reach A
  2. Advance past A's timeout for C
Expected:
  - A declares C gone; B still sees C present -> the peers transiently hold different participant sets
Open questions:
  - ANSWERED by DECISIONS #30 (mechanism revised 2026-06-05): A's local disconnect stands until either (a) B's newer beat reaches A by FORWARDING (grow-only-max gossip) before A crosses the tick, or (b) the disagreement surfaces as a B5 desync and B8 recovery reconciles it — carrying last-attendance-tick. Beats ARE forwarded now; convergence is eventual within the connected component. (The original pull-on-suspicion probe / B7.1 is superseded + deleted — see Category 18, `DESIGN_PARTICIPATION.md` §6.2.)
Status: [P] (sparse convergence = beat forwarding, design-stage; long-partition fallback B8)
```

```
S-005-06  Different peers detect a disconnect at different ticks — what tick does the network agree on?
Category: Attendance & liveness
Phase: B  Goal: B7
Preconditions:
  - 3 peers; C dies abruptly at wall-time T; A and B have slightly different clocks/timeouts
Actions:
  1. A's timeout for C elapses at A-tick 145; B's at B-tick 150
Expected:
  - Both A and B must ultimately place C's disconnect simulation-event at the SAME canonical tick, or they will desync
Open questions:
  - LOCAL half RESOLVED (B7, 2026-05-29, DECISIONS #29): the canonical disconnect tick IS lastAttendanceTick(C) + timeoutTicks — deterministic from the shared stamp, not local detection time; a newer beat only moves it FORWARD (grow-only-max) and forces a disconnect-conditional rollback (restart at the old tick) only if the peer already simulated past it.
  - SPARSE-CONVERGENCE half (DECISIONS #30): peers that heard DIFFERENT last beats reconcile via beat FORWARDING (grow-only-max gossip); the long-partition fallback is B5-desync/B8-recovery. This retires the old #17 "no special algorithm" stance. **SUPERSEDED 2026-06-05:** the original fast path was a pull-on-suspicion probe (B7.1, `DisconnectProbe.js`) — now DELETED with its tests (see Category 18, DECISIONS #30, `DESIGN_PARTICIPATION.md` §6.2). See Category 13/14 (local), Category 18 (historical), DisconnectTracker.js.
Status: [P] (local B7 done; sparse-convergence = beat forwarding, design-stage; fallback B8)   (disconnect-tracker.test.js; disconnect-tracker-harness.test.js; selftest-b7.mjs — S-013-01/02, S-014-01/02; the former probe tests for S-018-01..04 are removed)
```

```
S-005-07  A attendance-only spectator consumes no input slot
Category: Attendance & liveness
Phase: B  Goal: B2
Preconditions:
  - 2 active peers + 1 spectator S that only attendance
Actions:
  1. Advance 30 s
Expected:
  - S counts toward liveness/presence but not toward the active-participant set used by input-bearing logic
Open questions:
  - Does a spectator appear in the `players` array passed to tick, or in a separate presence list? (public-API surface decision)
Status: [~]
```

```
S-005-08  Rejoin after a disconnect
Category: Attendance & liveness
Phase: B  Goal: B7+B10
Preconditions:
  - Peer C was declared gone at tick 145; the same physical client reconnects at tick 300
Actions:
  1. C reconnects and bootstraps
Expected:
  - C is treated as a fresh participant (new opaque id) and bootstraps current state; the old C's disconnect event remains in history
Open questions:
  - Is identity continuity ever preserved across a reconnect (same participant id resumes), or is every reconnect always a brand-new participant? Game logic that keyed state by participant id is affected.
Status: [~]
```

---

### Category 18 — Predicate context freezing

The query API is `query(playerId, ctx, (input, ctx) => predicate)`. The frozen `ctx` is what re-evaluation sees, so re-simulation is deterministic regardless of how the caller's live state has drifted since.

```
S-018-01  A frozen ctx is immune to a later mutation of the caller's live state
Category: Predicate context freezing
Phase: B  Goal: B4
Preconditions:
  - A query at tick 10: query(A, liveCtx={threshold:0.5}, (i,c) => i.jump > c.threshold) with input jump=0.8 -> true
Actions:
  1. After the query returns, the caller mutates liveCtx.threshold = 0.95
  2. Re-evaluate the SAME input (jump=0.8) via recheck
Expected:
  - The recorded result stays true and recheck reports NO divergence (frozen ctx is 0.5, not 0.95)
  - Control: a naive re-read against the mutated live ctx (0.95) WOULD flip to false — exactly the false rollback freezing averts
Open questions: none
Status: [P]   (query-context.test.js "FREEZES ctx"; query-context-harness.test.js / selftest-b4.mjs "frozen ctx is immune")
```

```
S-018-02  recheck reports the earliest tick whose query result actually flips
Category: Predicate context freezing
Phase: B  Goal: B4
Preconditions:
  - Ticks 10 and 20 both query (i,c) => i.jump > c.threshold (threshold 0.5); both originally jump=0.1 -> false
Actions:
  1. Corrected input flips tick 20 to jump=0.9; tick 10 stays 0.1
  2. recheck(playerId, 0..100, inputAt)
Expected:
  - recheck returns 20 (the earliest flip); the rollback DECISION that consumes this is Goal B5, not B4
Open questions: none
Status: [P]   (query-context.test.js "earliest tick whose result actually flips")
```

```
S-018-03  Production mode adds zero overhead vs a raw closure
Category: Predicate context freezing
Phase: B  Goal: B4
Preconditions:
  - The same query workload run under debug=true and debug=false, with the clone fn instrumented
Actions:
  1. Run N queries in each mode
Expected:
  - Debug clones ctx N times; production clones 0 times and runs no mutation compare — the predicate call is the only work
  - (AI limitation: this is a structural proof via a clone-fn spy, not a wall-clock microbenchmark — see PROGRESS_LOG)
Open questions: none
Status: [P]   (query-context.test.js "does NOT clone ctx in production" / "clones N times in debug but 0 in production")
```

---

### Category 19 — Predicate evaluator purity

A predicate must be a pure function of `(input, ctx)`. Debug mode makes an impure predicate loud; production trusts the contract.

```
S-019-01  A predicate that mutates its ctx is caught and named in debug mode
Category: Predicate evaluator purity
Phase: B  Goal: B4
Preconditions:
  - A predicate `leakyPredicate(input, ctx) { ctx.hits++; return input.go; }` queried in debug mode
Actions:
  1. Run the query at tick 5
Expected:
  - onMutation fires exactly once, with tick=5, playerId, and a predicateSource that contains "leakyPredicate"
  - A pure predicate produces no warning
Open questions: none
Status: [P]   (query-context.test.js "catches a predicate that mutates ctx"/"does NOT warn for a pure predicate"; harness / selftest-b4.mjs "debug catches a ctx-mutating predicate")
```

```
S-019-02  An impure predicate's re-evaluation still starts from the clean frozen snapshot
Category: Predicate evaluator purity
Phase: B  Goal: B4
Preconditions:
  - A predicate that increments ctx.count and returns count <= 1, queried at tick 1 (ctx {count:0}) -> true
Actions:
  1. recheck the same tick with the same input
Expected:
  - Re-eval re-clones the snapshot (count:0 again) so it reproduces count 0->1 => true — no compounding, no spurious divergence
  - Production mode does NOT run the mutation check at all (trusts the contract)
Open questions: none
Status: [P]   (query-context.test.js "re-evaluation uses the clean pre-mutation snapshot"; "does NOT run mutation detection in production")
```

---

### Category 10 — Hash window exchange

Peers periodically broadcast `{oldestTick, interval, stateHashes[], usedInputs[]}` (stateHashes positional on a checkpoint grid). Comparison happens on the overlap of non-null checkpoints.

```
S-010-01  Agreeing peers exchange windows and stay in agreement
Category: Hash window exchange
Phase: B  Goal: B5
Preconditions:
  - 2 peers with identical timelines; interval=100; broadcasting their hash windows
Actions:
  1. Advance 500 ticks
Expected:
  - Every comparison returns "agree"; no desync is ever flagged; >0 windows were sent
Open questions: none
Status: [P]   (hash-window-harness.test.js "agreeing peers"; selftest-b5.mjs)
```

```
S-010-02  Comparison uses only the overlapping checkpoints when peers are at different lengths
Category: Hash window exchange
Phase: B  Goal: B5
Preconditions:
  - Peer A has checkpoints {100,200}; peer B has {100,200,300} (B is ahead); all shared hashes equal
Actions:
  1. compareHashWindows(A, B)
Expected:
  - status "agree", lastAgreedTick=200 (the unshared checkpoint 300 is ignored, not a divergence)
Open questions: none
Status: [P]   (hash-window.test.js "compares only the overlapping checkpoint ticks")
```

---

### Category 11 — Uncertainty-aware desync — false-positive avoidance

The headline v2 rule: a hash mismatch is NOT a desync while either peer still has unresolved relevant input uncertainty in the diverging window. Different state can be correct-given-different-knowledge.

```
S-011-01  Mismatch with a pending acceptance-window input does NOT trigger recovery
Category: Uncertainty-aware desync — false-positive avoidance
Phase: B  Goal: B5
Preconditions:
  - 2 peers agree at checkpoint 100, differ at 200; both hold an UNCONFIRMED relevant queried input at tick 150 (inside the diverging window), never confirmed in this run
Actions:
  1. Exchange and compare windows; advance 600 ticks
Expected:
  - Every comparison of the diverging window returns "wait" (divergeTick=200); desync is NEVER flagged
Open questions: none
Status: [P]   (hash-window-harness.test.js "FALSE positive avoided"; hash-window.test.js "wait, not desync"; selftest-b5.mjs)
```

```
S-011-02  Uncertainty on EITHER peer is enough to wait
Category: Uncertainty-aware desync — false-positive avoidance
Phase: B  Goal: B5
Preconditions:
  - Diverge at 200; the relevant input at 150 is confirmed on the local peer but UNCONFIRMED on the remote peer's window
Actions:
  1. compareHashWindows(local, remote)
Expected:
  - status "wait" — a single peer's unresolved uncertainty suffices
Open questions: none
Status: [P]   (hash-window.test.js "uncertainty on EITHER peer is enough to wait")
```

```
S-011-03  An unconfirmed input OUTSIDE the diverging window does not suppress a real desync
Category: Uncertainty-aware desync — false-positive avoidance
Phase: B  Goal: B5
Preconditions:
  - Agree at 100, diverge at 200 (window is (100,200]); an unconfirmed relevant input sits at tick 80 (before the last agreed checkpoint)
Actions:
  1. compareHashWindows
Expected:
  - status "desync" at 200 — inputs at/before the agreed checkpoint are already accounted for and cannot explain a later divergence
Open questions: none
Status: [P]   (hash-window.test.js "an unconfirmed input OUTSIDE the diverging window")
```

---

### Category 12 — Uncertainty-aware desync — true-positive detection

```
S-012-01  Mismatch with all relevant inputs confirmed is a real desync at the earliest diverging tick
Category: Uncertainty-aware desync — true-positive detection
Phase: B  Goal: B5
Preconditions:
  - 2 peers agree at 100, differ at 200; the relevant queried input at 150 is CONFIRMED on both
Actions:
  1. compareHashWindows / exchange windows
Expected:
  - status "desync", divergeTick=200, lastAgreedTick=100
Open questions: none
Status: [P]   (hash-window.test.js "all relevant inputs CONFIRMED => desync"; hash-window-harness.test.js "TRUE positive detected"; selftest-b5.mjs)
```

```
S-012-02  Divergence at the very first shared checkpoint with no uncertainty is a desync there
Category: Uncertainty-aware desync — true-positive detection
Phase: B  Goal: B5
Preconditions:
  - Peers differ already at the first shared checkpoint (100); no relevant uncertainty
Actions:
  1. compareHashWindows
Expected:
  - status "desync", divergeTick=100, lastAgreedTick=null (nothing was ever agreed)
Open questions: none
Status: [P]   (hash-window.test.js "diverging at the very FIRST shared checkpoint")
```

```
S-012-03  Detection is bounded: a "wait" flips to "desync" once the uncertain input confirms
Category: Uncertainty-aware desync — true-positive detection
Phase: B  Goal: B5
Preconditions:
  - Diverge at 200; the relevant input at 150 does not confirm until tick 500
Actions:
  1. Advance to tick 400 (input still unconfirmed)
  2. Advance to tick 600 (input now confirmed)
Expected:
  - At 400: status "wait", no desync; at 600: desync flagged, first detected at a tick >= 500
Open questions: none
Status: [P]   (hash-window-harness.test.js "bounded-time resolution"; hash-window.test.js "flips wait -> desync"; selftest-b5.mjs)
```

---

### Category 8 — Acceptance window edges

```
S-008-01  Input inside the acceptance window is accepted directly (raw)
Category: Acceptance window edges
Phase: B  Goal: B6
Preconditions:
  - WindowConfig acceptanceWindowMs=200, graceWindowMs=300
Actions:
  1. classifyInput at age 200 (raw), and at age 201 (raw)
Expected:
  - age 200 -> accept, tier "acceptance"; age 201 (raw) -> not accepted, tier "grace"
Open questions: none
Status: [P]   (acceptance-windows.test.js "acceptance tier" edge cases; acceptance-windows-harness.test.js "window edge through the clock")
```

```
S-008-02  Beyond the grace window: rejected, may trigger recovery
Category: Acceptance window edges
Phase: B  Goal: B6
Preconditions:
  - WindowConfig graceWindowMs=300
Actions:
  1. classifyInput at age 301 (raw and relayed)
Expected:
  - both -> reject-beyond-grace, mayTriggerRecovery=true, not applied
Open questions: none
Status: [P]   (acceptance-windows.test.js "beyond grace"; acceptance-windows-harness.test.js "beyond the grace window"; selftest-b6.mjs)
```

---

### Category 9 — Grace window relay

```
S-009-01  Raw vs relayed asymmetry inside the grace window
Category: Grace window relay
Phase: B  Goal: B6
Preconditions:
  - Same age (250ms) input arriving once relayed, once raw
Actions:
  1. classifyInput at age 250 relayed; at age 250 raw
Expected:
  - relayed -> accept; raw -> reject-raw-in-grace (a late raw input is not accepted directly)
Open questions: none
Status: [P]   (acceptance-windows.test.js "grace tier"; acceptance-windows-harness.test.js "raw vs relayed asymmetry"; selftest-b6.mjs)
```

```
S-009-02  Grace window enables convergence of a laggard via relay
Category: Grace window relay
Phase: B  Goal: B6
Preconditions:
  - 3 peers O (origin), R (relayer), A (laggard); A partitioned from O
Actions:
  1. O originates a raw input; R accepts it in its acceptance window and relays it
  2. A receives the relay inside its grace window
Expected:
  - A accepts via the grace tier (relayed) and converges with O and R; without the relay path A stays stranded
Open questions: none
Status: [P]   (acceptance-windows-harness.test.js "relay convergence" / "without a relay path the laggard stays stranded"; selftest-b6.mjs)
```

---

### Category 13 — Disconnect as simulation event

```
S-013-01  Canonical disconnect tick is deterministic from the shared stamp
Category: Disconnect as simulation event
Phase: B  Goal: B7
Preconditions:
  - timeoutTicks = 40; last attendance heard from C is tick-stamped 100
Actions:
  1. Compute canonicalDisconnectTick('C'); query queryDisconnected('C', 139/140/141)
Expected:
  - canonicalDisconnectTick = 140 (100 + 40), independent of when the beat was noted
  - queryDisconnected false at 139, true at 140 (inclusive) and 141
  - a never-heard participant has no disconnect tick and is not disconnected (that is passivity, not disconnection)
Open questions: none
Status: [P]   (disconnect-tracker.test.js "canonical/boundary"; resolves S-005-06)
```

```
S-013-02  A late newer attendance forces a disconnect-conditional retroactive rollback
Category: Disconnect as simulation event
Phase: B  Goal: B7
Preconditions:
  - A learned C@60 (disconnect tick 100) and already simulated past tick 100 (C treated absent)
Actions:
  1. A newer beat C@90 is learned at tick ~120 (AFTER the old disconnect tick)
Expected:
  - disconnect tick shifts 100 -> 130; ticks [100, 130) flip disconnected->connected
  - a rollback is recorded restarting at 100 (earliestAffectedTick); presentAt(C, 115) flips false->true
  - the same beat learned BEFORE tick 100 just extends the alive period — no rollback
Open questions: none
Status: [P]   (disconnect-tracker.test.js "late attendance shift"; disconnect-tracker-harness.test.js "retroactive rollback" / "no rollback when learned early"; selftest-b7.mjs)
```

### Category 14 — Disconnect agreement under partition

```
S-014-01  Peers that heard the SAME last beat agree regardless of link latency (local determinism)
Category: Disconnect agreement under partition
Phase: B  Goal: B7
Preconditions:
  - C self-beats once (tick 100) then goes silent; A hears it instantly, B hears it 200ms later — both via a DIRECT link
Actions:
  1. Advance past C's silence
Expected:
  - A and B compute the SAME canonical disconnect tick (140) — it derives from the shared STAMP, not arrival time (grow-only-max merge, DECISIONS #29)
Open questions: none (this is the LOCAL determinism guarantee)
Status: [P]   (disconnect-tracker-harness.test.js "latency-independent agreement"; selftest-b7.mjs)
```

```
S-014-02  A peer that never heard the silent peer holds null until the B5/B8 recovery fallback reconciles it
Category: Disconnect agreement under partition
Phase: B  Goal: B7 (local) + DECISIONS #30 (convergence)
Preconditions:
  - C self-beats then dies; A is partitioned from C for the whole run (no direct link), B has a direct link
Actions:
  1. Advance past C's silence (no attendance forwarding — attendance are never proactively relayed)
Expected:
  - B computes disconnect tick 140; A holds null for C (never-connected / passive, NOT disconnected)
  - convergence of A is NOT B7's job: it is the DECISIONS #30 sparse mechanism — beat FORWARDING (grow-only-max gossip) + B5-desync/B8-recovery fallback
Open questions:
  - beat forwarding (gossip) is what would carry C's beat to A across a hop — but it CANNOT help this specific case: A never heard ANY beat from C and has no path that supplies one, so its canonical tick is null. The never-heard case here is reconciled only by the B5-desync → B8-recovery fallback (carrying last-attendance-tick). This scenario asserts that honest limit. (The original pull-on-suspicion probe / B7.1 that once owned this convergence is superseded + deleted — Category 18, `DESIGN_PARTICIPATION.md` §6.2.)
Status: [P]   (disconnect-tracker-harness.test.js "honest limit: a peer with no direct link ... holds null"; selftest-b7.mjs) — the never-heard case is the recovery fallback's job
```

---

### Category 15 — Severe desync recovery

```
S-015-01  Two divergent groups converge to the OLDER history after heal (age beats id)
Category: Severe desync recovery
Phase: B  Goal: B8
Preconditions:
  - x-group {x1,x2}: state "alpha", simulationAge 10, HIGHER ids
  - a-group {a1,a2}: state "beta",  simulationAge 5,  LOWER ids
  - the two groups are partitioned (each internally agrees, no cross traffic)
Actions:
  1. Advance under partition (groups stay divergent: a1 still "beta")
  2. Heal the partition; advance
Expected:
  - all four peers converge to "alpha" — the OLDER simulation wins authority; age DOMINATES the lower-id tiebreak (else "beta" would win)
  - the younger a-group adopted via a point-to-point state transfer; the older x-group never adopted (no flooding)
Open questions: none
Status: [P]   (recovery-harness.test.js "two divergent groups converge ..."; selftest-b8.mjs; resolveDesync/compareAuthority in recovery.test.js)
```

### Category 16 — Lagging peer wake

```
S-016-01  A far-behind stale peer resets its age, yields, and adopts the live state (without dominating)
Category: Lagging peer wake
Phase: B  Goal: B8
Preconditions:
  - live network {n1,n2}: state "live", simulationAge 10, known attendance C@200
  - old1: state "stale", simulationAge 1000 (would dominate on raw age), wakes up LATE so it is many ticks behind; lagThresholdTicks small; known attendance C@50
Actions:
  1. Advance; old1 hears the network tick is >= threshold ahead of its own
Expected:
  - old1 RESETS its authority age to 0 (the youngest), so its stale history yields; it then adopts "live"
  - n1/n2 are NOT polluted (stay "live")
  - the state transfer reconciles old1's attendance C@50 -> C@200 grow-only-max (the DECISIONS #30 slow-path fallback)
Open questions: none
Status: [P]   (recovery-harness.test.js "lagging peer wake ..."; selftest-b8.mjs)
```

```
S-016-02  CONTROL: with the lag reset DISABLED the stale peer dominates and pollutes the network
Category: Lagging peer wake
Phase: B  Goal: B8
Preconditions:
  - same as S-016-01 but old1 has the lag reset disabled (lagThresholdTicks=null)
Actions:
  1. Advance
Expected:
  - old1 keeps simulationAge 1000, WINS authority, and the network adopts its stale state ("live" -> "stale")
  - proves the lag reset in S-016-01 is load-bearing, not incidental
Open questions: none
Status: [P]   (recovery-harness.test.js "CONTROL: with the lag reset DISABLED ..."; selftest-b8.mjs)
```

### Category 17 — Authority tie-break

```
S-017-01  Equal simulation age is broken by the LOWER peerId
Category: Authority tie-break
Phase: B  Goal: B8
Preconditions:
  - peer 'a': state "sA", simulationAge 7
  - peer 'b': state "sB", simulationAge 7  (equal age, higher id)
Actions:
  1. Advance until they reconcile
Expected:
  - both converge to "sA" — the lower-id history wins; 'b' adopted, 'a' never did
  - compareAuthority is a TOTAL order: it never returns an undecided 0 for two distinct peers
Open questions: none
Status: [P]   (recovery-harness.test.js "authority tie-break ..."; selftest-b8.mjs; compareAuthority total-order tests in recovery.test.js)
```

---

### Category 18 — Sparse disconnect-tick convergence (DECISIONS #30, Goal B7.1) — SUPERSEDED 2026-06-05

> **SUPERSEDED + REMOVED.** This category specified the pull-on-suspicion **probe** (`disconnectSuspicion`/`attendanceCorrection`). The probe is DELETED — `DisconnectProbe.js`/`ProbeNode.js` and the tests that pinned these scenarios (`disconnect-probe*.test.js`, `engine-l3-probe.test.js`, `selftest-b7.1.mjs`) are removed, and the engine is unwired. Convergence is now **beat forwarding (grow-only-max gossip)** with the B5→B8 last-attendance-tick transfer as the backstop. The scenarios below (S-018-01..04: fast-path pull, relevance gate, amplification suppression, no-spurious-un-disconnect) no longer describe shipped behaviour and are retained only for history. Replacement test intents live in `DESIGN_PARTICIPATION.md` §15 (beat convergence is order- and loss-free, no ack machinery). See DECISIONS #30 + `DESIGN_PARTICIPATION.md` §6.2.

#### (historical) Category 18 scenarios

```
S-018-01  FAST PATH: an older-knowledge peer pulls the newer beat before crossing the disconnect tick
Category: Sparse disconnect-tick convergence
Phase: B  Goal: B7.1
Preconditions:
  - A heard C@60 (canonical Y=100); B heard C@90 (canonical Y=130); C is silent; A.relevant=['C']; probeLeadTicks=8
  - A<->B linked (latency 20ms each way)
Actions:
  1. Advance; A's probe opens at tick 92 (Y-lead) and broadcasts disconnectSuspicion{C,100}
  2. B replies attendanceCorrection{C,90} (strictly newer)
Expected:
  - A applies the correction grow-only-max => canonical(C)=130 BEFORE A reaches tick 100, so C is never falsely disconnected and NO rollback is paid
  - exactly ONE suspicion sent (sparse; deduped) and ONE correction (B); presentAt(C,100)=true
Open questions: none
Status: [P]   (disconnect-probe-harness.test.js "FAST PATH ..."; selftest-b7.1.mjs; DisconnectProbe unit tests)
```

```
S-018-02  RELEVANCE GATE (control): C not relevant => no probe => A disconnects C at its stale tick
Category: Sparse disconnect-tick convergence
Phase: B  Goal: B7.1
Preconditions:
  - identical to S-018-01 EXCEPT A.relevant=[] (C is not queried)
Actions:
  1. Advance past tick 100
Expected:
  - A emits NO suspicion; B's newer beat is never pulled; A disconnects C at the stale tick 100 (presentAt(C,100)=false)
  - proves the relevance-gated suspicion is the load-bearing mechanism (honest pre-probe state when not relevant)
Open questions: none
Status: [P]   (disconnect-probe-harness.test.js "RELEVANCE GATE ..."; selftest-b7.1.mjs)
```

```
S-018-03  AMPLIFICATION SUPPRESSION: a second newer-beat holder backs off after seeing the first correction
Category: Sparse disconnect-tick convergence
Phase: B  Goal: B7.1
Preconditions:
  - A heard C@60 (relevant); B and D BOTH heard C@90; latencies tuned so B's correction reaches D before D hears A's suspicion
Actions:
  1. A broadcasts the suspicion; B replies; B's correction reaches D first; then D hears the suspicion
Expected:
  - only B broadcasts a correction; D observes a correction >= its own beat and stays silent (no reply storm)
  - A still converges: canonical(C)=130
Open questions: none
Status: [P]   (disconnect-probe-harness.test.js "AMPLIFICATION SUPPRESSION ..."; selftest-b7.1.mjs; onSuspicion backoff unit tests)
```

```
S-018-04  NO SPURIOUS UN-DISCONNECT: a genuinely dead peer still disconnects when nobody holds a newer beat
Category: Sparse disconnect-tick convergence
Phase: B  Goal: B7.1
Preconditions:
  - C self-beats through tick 30 then dies (last beat 30 => Y=70); A and B both heard it live; A.relevant=['C']
Actions:
  1. A's probe opens and broadcasts a suspicion; no peer holds a strictly-newer beat
Expected:
  - no correction is sent; the probe invents NO phantom attendance; C disconnects at exactly tick 70 (presentAt(C,69)=true, presentAt(C,70)=false), no rollback
Open questions: none
Status: [P]   (disconnect-probe-harness.test.js "NO SPURIOUS UN-DISCONNECT ..."; selftest-b7.1.mjs)
```

---

### Category 20 — Tick finalization + memory bounding (Goal B9)

```
S-020-01  BOUNDED-GROWTH PLATEAU: retention is a function of phase, not session length
Category: Tick finalization + memory bounding
Phase: B  Goal: B9
Preconditions:
  - one node; graceWindowTicks=6, snapshotEveryTicks=5, two participants changing every 3 ticks + one one-shot participant; GC on each tick
Actions:
  1. Advance to tick 600; record retainedCount
  2. Advance to tick 1200 (same phase: both multiples of 5 and 3); record retainedCount
Expected:
  - retainedCount is IDENTICAL at 600 and 1200 (=17: 7 query-log + 3 snapshot + 6 input + 1 one-shot) — retention asymptotes, does NOT grow with session length
Open questions: heap-byte growth under a real engine is a B-Integrate / Phase C measurement; here bounding is proven by retained-entry count
Status: [P]   (finalization-harness.test.js "BOUNDED-GROWTH PLATEAU ..."; selftest-b9.mjs)
```

```
S-020-02  GC-DISABLED CONTROL: the same workload grows without bound (proves the GC is load-bearing)
Category: Tick finalization + memory bounding
Phase: B  Goal: B9
Preconditions:
  - identical workload to S-020-01 EXCEPT gcEnabled=false
Actions:
  1. Advance to tick 600; record retainedCount (=1121)
  2. Advance to tick 1200; record retainedCount (=2241)
Expected:
  - retention grows ~linearly with tick count (1121 -> 2241) and is two orders of magnitude above the GC-on plateau (17) — the GC is what bounds memory, not an incidental property of the workload
Open questions: none
Status: [P]   (finalization-harness.test.js "GC-DISABLED CONTROL ..."; selftest-b9.mjs)
```

```
S-020-03  ROLLBACK-ANCHOR INVARIANT: a snapshot <= horizon is always retained; no query log below it
Category: Tick finalization + memory bounding
Phase: B  Goal: B9
Preconditions:
  - workload of S-020-01 at tick 600 (finalization horizon = 600 - grace 6 = 594)
Actions:
  1. Inspect retained snapshot ticks and query-log ticks
Expected:
  - at least one retained snapshot is at-or-before the horizon (the re-sim anchor; here 590 — losing it would make a still-mutable tick un-reconstructable)
  - NO retained query-log tick is strictly below the horizon (a finalized tick can never be rechecked); retained query logs are exactly [594..600]
Open questions: none
Status: [P]   (finalization-harness.test.js "ROLLBACK-ANCHOR INVARIANT ..."; selftest-b9.mjs; collectAnchored/collectBelow unit tests)
```

```
S-020-04  SPARSE CARRY-FORWARD: a change-once-then-silent participant keeps exactly its last tick
Category: Tick finalization + memory bounding
Phase: B  Goal: B9
Preconditions:
  - a participant 'q' that changed once at tick 2 then went silent forever; advance to tick 1200
Actions:
  1. Inspect q's retained input ticks
Expected:
  - q retains EXACTLY [2] — its last value is the carry-forward anchor, never collected and never duplicated, regardless of how long it stays silent
Open questions: none
Status: [P]   (finalization-harness.test.js "SPARSE CARRY-FORWARD ..."; selftest-b9.mjs; collectAnchored single-anchor unit test)
```

---

### Category 21 — Random-peer bootstrap + catching-up state (Goal B10)

```
S-021-01  UNIFORM SERVING-PEER DISTRIBUTION: serving cost spreads, no single point of pressure
Category: Random-peer bootstrap
Phase: B  Goal: B10
Preconditions:
  - a fixed pool of 5 LIVE servers (S0..S4); 100 sequential joiners, each picking a server with a shared seeded rng, then disconnecting once caught up
Actions:
  1. Each joiner selects ONE serving peer via selectServingPeer(candidates, rng) and sends one boot-req
  2. Tally each server's servedCount
Expected:
  - total served == 100 (every join served exactly once)
  - each server's count within [8, 34]; chi-square (df=4) < 13.28 (p=0.01) — comfortably uniform, no load sink
Open questions: none
Status: [P]   (bootstrap-harness.test.js "UNIFORM SERVING-PEER DISTRIBUTION ..."; selftest-b10.mjs; selectServingPeer uniformity unit test, 100000 draws chi<18.47)

S-021-02  SPARSE REQUEST: a single join contacts exactly ONE server (no broadcast flood)
Category: Random-peer bootstrap
Phase: B  Goal: B10
Preconditions:
  - 3 LIVE servers, 1 joiner
Actions:
  1. The joiner picks one server and sends a single reliable point-to-point boot-req
Expected:
  - exactly one server's servedCount moves (sum == 1), and it is the chosen server — never N replies from a broadcast (sparseness constraint)
Open questions: none
Status: [P]   (bootstrap-harness.test.js "SPARSE REQUEST ..."; selftest-b10.mjs)

S-021-03  CATCHING-UP LIFECYCLE: enter on join, leave when caught up, ends LIVE and adopts state
Category: Random-peer bootstrap
Phase: B  Goal: B10
Preconditions:
  - one server at tick 10, grace 6 (edge tick 4), snapshot {score:42}; one joiner with enter/leave callbacks
Actions:
  1. The joiner connects (fires onEnterCatchingUp), requests a bootstrap, adopts the snapshot, re-simulates edge -> present
Expected:
  - exactly one enter/leave pair in order; status == 'live'; chosenServer == 'S'; adopted state == {score:42}; tick == 10 (the captured present); catchUpEvents[1].tick == 10
Open questions: none
Status: [P]   (bootstrap-harness.test.js "CATCHING-UP LIFECYCLE ..."; selftest-b10.mjs; CatchUpTracker monotonic unit tests)

S-021-04  RE-SIM FIDELITY: the joiner reconstructs each participant intent from baseline + since-edge log
Category: Random-peer bootstrap
Phase: B  Goal: B10
Preconditions:
  - server at tick 10, grace 6 => edge 4; baselineInputs {P:{m:1}}; inputLog [{P,6,{m:2}},{Q,8,{fire:true}}] (Q only in the log, never in the baseline)
Actions:
  1. The joiner adopts the payload and rebuilds per-participant decoders; inspect reconstructed intent at several ticks
Expected:
  - P@4={m:1} (baseline at edge), P@5={m:1} (held), P@6={m:2} (since-edge change); Q@4=null (never-heard => passive, B3), Q@8={fire:true}
Open questions: none
Status: [P]   (bootstrap-harness.test.js "RE-SIM FIDELITY ..."; selftest-b10.mjs; reconstructInputs unit tests)
```

---

### Categories 6–7, 22–34

*(populated incrementally as their corresponding Phase B / C goals are tackled — Meta-Goal M2.)*

> **Categories 22–28 added 2026-06-05 (design-spec / TDD targets).** These translate the `research/synced-clock/DESIGN_PARTICIPATION.md` participation + liveness + messaging protocol into executable vitest specs under `tests/design-2*.test.js`. Per the Status Legend, `[P]` scenarios pass today against implemented APIs; `[T]` scenarios are committed **INTENTIONAL REDS** — they assert the DESIRED behavior of unbuilt features (conditional join, the announced-set / `discoverParticipants` API, `autoRelease` flags, the read/poll loud-fail contract, participation-gated retention, **beat forwarding for connected multi-hop networks**, and the **lossy/reordering input wire protocol**) and currently FAIL by design. They flip green when the feature lands. Round 1 added Categories 22-28 (headline behaviors); round 2 added Categories 29-34 (the fine-grained mechanics round 1 left untested — beat ×3/delta-ack/send-triggers, input delay `D`, the membership×liveness quadrants, retention/avatar-persistence, the 2×2 flag matrix + adversarial rollback determinism, and read-API prod/rollback semantics). Categories 35-36 (added later) pin the §6.4 reconnect protocol and the §5.1 tick guard (liveness as rolled-back state + the agreed reactivation tick). As of this writing: **133 scenarios, 133 `[P]`, 0 `[T]`** — **implementation P0+P1 + Fork E + tick guard + beat forwarding + the §10.1 lossy input wire protocol (Cat 27) + §10.2 per-peer delta forwarding (Cat 37) + input delay `D` (Cat 30) + the §7/§8 read API + polling contract (Cat 24/34) + the §6.1/§6.2/§10.1 beat + wire fine-mechanics (Cat 29: §6.2 dev-assert, discoverable-as-first-beat proof-of-life, `shouldForwardBeat` predicate, value-keyed beat-repeat counter, delta-against-ackId, per-send reason trace, monotonic msg seq) have landed.** Of `design-27-wire-protocol-limits.test.js`: (a) "shared-hole repair serializes" was FIXED by §10.2 (the throttle is now per-(peer,source,tick)) — relabelled to a passing regression guard; (b) "a FINALIZED input hole has no convergence backstop" stays `[T]` (B8 state transfer does NOT reconcile a divergent input history against an active producer — bootstrap/B10 territory). (announced-set core + departure/flag machinery: `participants()` is the announced set, plus `reconstructedIds()`, `isDisconnected`, `players()`, `allParticipantsDropped`, `getStoppedParticipating`, `stopParticipating`, `releaseParticipant`, the `autoRelease` two-flag matrix; the §6.4 reconnect/reactivation tick; the §5.1 tick guard; opt-in beat forwarding; and the opt-in `inputRedundancy` wire protocol — redundancy + SACK fast-retransmit + adaptive RTO). The §3/§12 DROP-ON-SIGHT headline is now implemented (opt-in `participationRetention`: a non-discoverable input from an irrelevant source — not a participant, no live discoverable — is never stored; Cat 25#1 + Cat 28 green). The remaining `[T]` reds are the DEEPER retention architecture: decoder floor-pruning ("input array welded to the floor" — Cat 25 §9 case-1/2/3, Cat 32 span/logical-physical/no-hoard), `engineFingerprint` driven by STATE not the raw input log (Cat 32 symmetry), self-input-drop accounting + late-promotion (Cat 25 §10), the Cat 22 departure-idempotence red, and the §10.1/§10.2 finalized-tail backstop (limit (b)).


### Category 22 — Conditional join: discoverable promotion and announce/denounce determinism (design-spec; DESIGN_PARTICIPATION.md sections 1, 2.1, 2.2, 2.3, 3, 5)
Asserts the announced-set model: discoverable inputs promote spectators via a deterministic discovery query with a limit (re-derived identically on every node and on rollback, incl. the §5 queue-pop trap), non-discoverable inputs never promote, conditional join over frozen context, self-consuming/idempotent getStoppedParticipating, and immediate releaseParticipant. The two participants()-baseline scenarios PASS today; the seven announced-set scenarios are RED design-targets (discoverParticipants/isParticipant/getStoppedParticipating/releaseParticipant/discoverable are ABSENT).

```
S-022-01  Normal input materializes a participant on every node
Category: Conditional join — discoverable promotion and announce/denounce determinism
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed 22, nodes A,B,C via SimulationEngine factory; A presses {v:1} from tick 0, B and C passive
Actions:
  1. Advance 20 ticks (TICK_MS=50)
Expected:
  - A's input change reaches all nodes; participants() on A, B, C each contains 'A'
  - B.participants() === C.participants() (membership is derived, identical across nodes)
  - A.getState() === {sum:20}
Status: [P]    (tests/design-22-conditional-join.test.js "should materialize a sending peer as a participant id, identically on every node")
```
```
S-022-02  Purely-passive spectator is never listed
Category: Conditional join — discoverable promotion and announce/denounce determinism
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed 22, A presses {v:1} from tick 0, B never presses anything
Actions:
  1. Advance 15 ticks
Expected:
  - A.participants() does NOT contain 'B' (no input change emitted)
  - A.participants() contains 'A'
Status: [P]    (tests/design-22-conditional-join.test.js "should NOT list a purely-passive spectator that never sends an input")
```
```
S-022-03  Lowest-2 ids win when N racers send discoverable joinGame (limit)
Category: Conditional join — discoverable promotion and announce/denounce determinism
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed 22, nodes A,B,C,D each press a discoverable joinGame at tick 0; limit=2 slots
Actions:
  1. Advance 20 ticks
  2. A.discoverParticipants((ctx,input)=>input.kind==='joinGame', 2)
Expected:
  - Admitted ids === ['A','B'] (lowest two in id order)
  - A.isParticipant('A')/('B') === true; isParticipant('C')/('D') === false
Status: [P]    (tests/design-22-conditional-join.test.js "should promote only the lowest-2 ids when N racers send a discoverable joinGame (§2.1 limit)")
```
```
S-022-04  Winner re-derived identically on every node from the finalized stream
Category: Conditional join — discoverable promotion and announce/denounce determinism
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed 22, A,B,C each press discoverable joinGame; A<->C and B<->C links latency 70ms; limit=1
Actions:
  1. Advance 25 ticks
  2. Each node runs discoverParticipants(joinGame predicate, 1)
Expected:
  - Every node independently selects ['A'] (lowest id), no negotiation (membership is derived, §1)
Status: [P]    (tests/design-22-conditional-join.test.js "should re-derive an IDENTICAL winner on every node from the same finalized stream (§1 derived, not stored)")
```
```
S-022-05  Late lower-id joinGame re-decides the winner (queue-pop trap)
Category: Conditional join — discoverable promotion and announce/denounce determinism
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed 22, B joins tick 0; A's discoverable joinGame arrives late (A->B latency 200ms, B->A 0ms); limit=1
Actions:
  1. Advance 30 ticks (A's join lands in B's non-finalized window after B was provisional winner)
Expected:
  - Rollback re-derives lower id A as winner on BOTH nodes (not kept as B by a drained pending queue, §5)
  - B.isParticipant('A') === true
Status: [P]    (tests/design-22-conditional-join.test.js "should re-decide the winner when a late LOWER-id joinGame lands in the non-finalized window (§5 queue-pop trap)")
```
```
S-022-06  Non-discoverable input never promotes, even if predicate matches
Category: Conditional join — discoverable promotion and announce/denounce determinism
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed 22, S sends {v:1,kind:'left',discoverable:false} every tick; A sends discoverable joinGame
Actions:
  1. Advance 20 ticks
  2. A.discoverParticipants((ctx,input)=>input.v>0, 5)
Expected:
  - Admitted ids exclude 'S'; A.isParticipant('S') === false (§3 discoverable-gating)
Status: [P]    (tests/design-22-conditional-join.test.js "should NEVER promote a spectator whose input is non-discoverable, even if it satisfies the predicate (§3)")
```
```
S-022-07  Conditional join over frozen context (join iff predicate holds)
Category: Conditional join — discoverable promotion and announce/denounce determinism
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed 22, A grabAt slot 3 (where ball is), B grabAt slot 9 (wrong); both discoverable
Actions:
  1. Advance 20 ticks
  2. A.discoverParticipants((ctx,input)=>input.kind==='grabAt'&&input.slot===3, 4)
Expected:
  - Admitted === ['A']; A.isParticipant('A') === true, isParticipant('B') === false (join iff predicate over frozen ctx)
Status: [P]    (tests/design-22-conditional-join.test.js "should join IFF a frozen-context predicate holds — conditional join over frozen ctx (§2.1)")
```
```
S-022-08  getStoppedParticipating is self-consuming and idempotent under re-sim
Category: Conditional join — discoverable promotion and announce/denounce determinism
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed 22, A joins; B joins tick 0 then emits stopParticipating at tick 10
Actions:
  1. Advance 30 ticks
  2. A.getStoppedParticipating() twice
Expected:
  - First drain reports 'B' exactly once (consuming IS the denounce); second drain excludes 'B'
  - A.isParticipant('B') === false (derived transition, not a drained side-queue, §5)
Status: [P]    (tests/design-22-conditional-join.test.js "should report a departure once via self-consuming getStoppedParticipating, idempotent under re-sim (§2.2/§5)")
```
```
S-022-09  releaseParticipant is an immediate unconditional demote
Category: Conditional join — discoverable promotion and announce/denounce determinism
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed 22, A and B both press discoverable joinGame; both promoted via discoverParticipants(limit 2)
Actions:
  1. Advance 20 ticks; promote both; A.releaseParticipant('B')
Expected:
  - A.isParticipant('B') === false immediately (no consume, no predicate, governed by no flag, §2.3)
  - A.isParticipant('A') === true
Status: [P]    (tests/design-22-conditional-join.test.js "should immediately and unconditionally demote on releaseParticipant — governed by no flag (§2.3)")
```

### Category 23 — autoRelease two-flag matrix and reservation (disconnect is not leave) (design-spec; DESIGN_PARTICIPATION.md sections 4, 6)
Covers the Flag1×Flag2 autoRelease matrix (Flag1 ON=disconnect→leave at the agreed tick; OFF=blip/reservation, only IsDisconnected flips, reconnect without re-admission; Flag2 AUTO=demote at signal tick vs CONSUME=id persists until getStoppedParticipating() consumed) plus releaseParticipant (flag-independent, immediate). 3 scenarios PASS today on the B7 disconnect-tick substrate (participants() + queryDisconnected); the 6 flag/reservation behaviors are RED design-targets (no autoRelease flags / stopParticipating / consume / isParticipant / isDisconnected / releaseParticipant API exists yet).

```
S-023-01  Agreed disconnect tick (the signal tick the flags act on)
Category: autoRelease two-flag matrix and reservation
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed=23, peers A,B,C with attendance (interval 100ms, timeout 200ms), each pressing v=1
Actions:
  1. Advance through tick 10 so C's last beat is heard by A and B
  2. partition(C,A) and partition(C,B); advance to tick 40
Expected:
  - A and B both compute canonicalDisconnectTick('C') === 14 (lastBeat 10 + timeout 4)
  - queryDisconnected('C',13)===false and queryDisconnected('C',14)===true on A
Status: [P]    (tests/design-23-autorelease-flags.test.js "should agree on the canonical disconnect tick for a blipped participant (the agreed signal tick the flags act on)")
```

```
S-023-02  Blipped id stays in participants() today (disconnect is not a leave by default)
Category: autoRelease two-flag matrix and reservation
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed=23, peers A,B,C with attendance, each pressing v=1
Actions:
  1. Advance through tick 10; partition C from A and B; advance to tick 40
Expected:
  - A.participants() still contains 'C' (no autoRelease wiring => slot held, de-facto reservation)
  - A.queryDisconnected('C',40)===true (only the liveness bit is set)
Status: [P]    (tests/design-23-autorelease-flags.test.js "should keep a blipped id inside participants() today (no autoRelease => disconnect is NOT a leave by default)")
```

```
S-023-03  Survivors converge to the hand-computed live-sum across the disconnect
Category: autoRelease two-flag matrix and reservation
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed=23, peers A,B,C with attendance, each pressing v=1; step sums only connected participants
Actions:
  1. Advance through tick 10; partition C from A and B; advance to tick 40
Expected:
  - A.getState() and B.getState() both equal { sum: 94 } (14*3 + 26*2; C drops at agreed tick 14)
Status: [P]    (tests/design-23-autorelease-flags.test.js "should converge survivors to the same hand-computed live-sum across the disconnect (deterministic substrate)")
```

```
S-023-04  Flag1 ON: disconnect becomes a leave (auto demote + reported departure)
Category: autoRelease two-flag matrix and reservation
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed=23, A,B,C with autoRelease:{ disconnectLeaves:true, demote:'auto' }; C disconnects at tick 14
Actions:
  1. Call A.getStoppedParticipating() and inspect A.participants()
Expected:
  - getStoppedParticipating is a function and reports 'C'; C is removed from participants() (slot freed at the agreed leave tick)
Status: [T]    (tests/design-23-autorelease-flags.test.js "Flag1 ON: should convert a disconnect into a leave — C drops out of participants() and is reported by getStoppedParticipating()")
```

```
S-023-05  Flag1 OFF: reservation — slot held, only IsDisconnected flips, no stopParticipating
Category: autoRelease two-flag matrix and reservation
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed=23, A,B,C with autoRelease:{ disconnectLeaves:false, demote:'auto' }; C disconnects at tick 14
Actions:
  1. Query A.isParticipant('C'), A.isDisconnected('C'), A.getStoppedParticipating()
Expected:
  - isParticipant('C')===true (never un-admitted); isDisconnected('C')===true; getStoppedParticipating() does NOT contain 'C'
Status: [T]    (tests/design-23-autorelease-flags.test.js "Flag1 OFF: should hold the slot (reservation) — C stays announced, isDisconnected flips, NO stopParticipating emitted")
```

```
S-023-06  Flag1 OFF reconnect: resume without re-admission on one agreed reactivation tick
Category: autoRelease two-flag matrix and reservation
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed=23, A,B,C with autoRelease:{ disconnectLeaves:false, demote:'auto' }; C blips then heals
Actions:
  1. Partition C from A,B through tick 20; heal C; advance to tick 40
Expected:
  - A.isParticipant('C')===true (no re-admission); A.isDisconnected('C')===false; A.reconnectTick('C')===B.reconnectTick('C') (one agreed tick)
Status: [T]    (tests/design-23-autorelease-flags.test.js "Flag1 OFF reconnect: should resume WITHOUT re-admission — IsDisconnected flips back false on one agreed reactivation tick")
```

```
S-023-07  Flag2 AUTO: slot freed at the signal tick with zero dev effort
Category: autoRelease two-flag matrix and reservation
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed=23, A,B,C with autoRelease:{ disconnectLeaves:true, demote:'auto' }; C disconnects at tick 14
Actions:
  1. Without calling getStoppedParticipating(), query A.isParticipant('C') and A.participants()
Expected:
  - isParticipant('C')===false and participants() lacks 'C' (Model-A vanish, no consume needed)
Status: [T]    (tests/design-23-autorelease-flags.test.js "Flag2 AUTO: should free the slot AT the signal tick with zero dev effort (Model-A vanish)")
```

```
S-023-08  Flag2 CONSUME: id persists until getStoppedParticipating() is consumed (lockstep)
Category: autoRelease two-flag matrix and reservation
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed=23, A,B,C with autoRelease:{ disconnectLeaves:true, demote:'consume' }; C disconnects at tick 14
Actions:
  1. Check A.participants() before consuming; call A.getStoppedParticipating(); check A.participants() after
Expected:
  - Before consume: participants() contains 'C'; report contains 'C'; after consume: participants() lacks 'C'
Status: [T]    (tests/design-23-autorelease-flags.test.js "Flag2 CONSUME: should keep the departed id in participants() until getStoppedParticipating() is consumed (lockstep)")
```

```
S-023-09  releaseParticipant: immediate demote regardless of either flag
Category: autoRelease two-flag matrix and reservation
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed=23, A,B,C with autoRelease:{ disconnectLeaves:false, demote:'consume' } (OFF+consume would hold the slot)
Actions:
  1. Confirm participants() contains 'C'; call A.releaseParticipant('C'); re-check participants()
Expected:
  - releaseParticipant is a function; C is removed immediately without any consume or signal-tick wait (flag-independent)
Status: [T]    (tests/design-23-autorelease-flags.test.js "releaseParticipant: should demote IMMEDIATELY regardless of either flag (direct command, governed by neither)")
```

### Category 24 — Polling contract, loud-fail and read API (players/handles) (design-spec; DESIGN_PARTICIPATION.md sections 7, 8)
Covers the §7 per-tick polling contract (dev-throw vs sync-safe prod-default on poll of a non-participant / disconnected participant) and the §8 read API (players() snapshot respecting consumption, per-tick capability handles, store-the-id-never-the-handle hash hygiene). **IMPLEMENTED — all 10 scenarios now `[P]`:** `poll(id)` (dev-throw / prod-`defaultIntent`), `players()` returning per-tick `ParticipantHandle`s in `devMode` (bare ids in prod), `readVia(handle)` with past-tick revocation, and `hashValue(value)` that dev-throws on an embedded handle. Internals use a private `_playerIds()` so handles never leak into engine logic. The retired-model peers were RECONCILED to actually ANNOUNCE (discoverable joinGame + in-step `discoverParticipants`); one test had a `(input)=>` predicate bug (the API passes `(ctx, input)`).

```
S-024-01  participants() agrees across nodes
Category: Polling contract, loud-fail and read API
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed 24, 3 active attendance peers A,B,C, 20 ticks elapsed
Actions:
  1. Read participants() on A, B, C
Expected:
  - Each node returns the same sorted roster ['A','B','C']
  - Self id is always present in the roster
Status: [P]    (tests/design-24-polling-contract.test.js "should expose participants() that agrees across nodes (the announced-set read baseline)")
```

```
S-024-02  queryDisconnected boundary agrees on both nodes
Category: Polling contract, loud-fail and read API
Phase: B  Goal: participation
Preconditions:
  - seed 24, A,B,C attending; C partitioned away after its tick-10 beat
Actions:
  1. Advance past the canonical disconnect tick
  2. Read canonicalDisconnectTick('C') and queryDisconnected('C', t) on A and B
Expected:
  - Both survivors compute canonical tick 14 (lastBeat 10 + timeout 4)
  - queryDisconnected('C',13)=false and ('C',14)=true identically on both
Status: [P]    (tests/design-24-polling-contract.test.js "should agree on queryDisconnected / canonicalDisconnectTick on both sides of the boundary")
```

```
S-024-03  reconstructedValueAt is replay-stable and survives rollback
Category: Polling contract, loud-fail and read API
Phase: B  Goal: participation
Preconditions:
  - seed 24, A presses v=7 at tick 3; B hears it late via 90ms link (rollback on B)
Actions:
  1. Advance 20 ticks
  2. Read reconstructedValueAt('A', tick) and inputChanges('A') on A and B
Expected:
  - B.rollbackCount() >= 1
  - reconstructedValueAt('A',10) == {v:7} on both nodes; pre-change value identical
  - inputChanges('A') identical on both nodes (durable id-keyed read)
Status: [P]    (tests/design-24-polling-contract.test.js "should reconstruct a participant input identically on every node and survive a late-change rollback")
```

```
S-024-04  isParticipant(id) is deterministic and node-agnostic (§7)
Category: Polling contract, loud-fail and read API
Phase: B  Goal: participation
Preconditions:
  - seed 24, peers A,B
Actions:
  1. Call isParticipant('A'/'B'/'Z') on A
  2. Cross-check isParticipant for every roster id on B
Expected:
  - Known announced ids => true, unknown id 'Z' => false, identical on A and B
Status: [P]    (tests/design-24-polling-contract.test.js "should expose a deterministic isParticipant(id) that agrees with participants() on every node")
```

```
S-024-05  dev-mode poll loud-fails on departed/non-participant (§7)
Category: Polling contract, loud-fail and read API
Phase: B  Goal: participation
Preconditions:
  - seed 24, devMode peers A,B,C; C disconnected (canonical tick 14)
Actions:
  1. poll('Z'), poll('C'), poll('A') on A in dev mode
Expected:
  - poll(non-participant 'Z') throws; poll(disconnected 'C') throws
  - poll(connected 'A') does not throw
Status: [P]    (tests/design-24-polling-contract.test.js "should DEV-THROW on poll(non-participant) and poll(disconnected participant) (§7 loud-fail)")
```

```
S-024-06  prod poll returns sync-safe default identically on every node (§7)
Category: Polling contract, loud-fail and read API
Phase: B  Goal: participation
Preconditions:
  - seed 24, prod-mode peers A,B,C; C disconnected
Actions:
  1. poll('C') on A and B; poll('Z') on A
Expected:
  - Both nodes agree on the denounce tick
  - poll('C') returns the identical tick-derived default on A and B
  - the non-participant default matches the disconnected default (one canonical zero)
Status: [P]    (tests/design-24-polling-contract.test.js "should return a sync-safe PROD default identically on every node for a disconnected poll (§7)")
```

```
S-024-07  players() respects consumption on a promoting discover (§8)
Category: Polling contract, loud-fail and read API
Phase: B  Goal: participation
Preconditions:
  - seed 24, peer A plus discoverable spectator S (discoverable input flag)
Actions:
  1. a = players(); discoverParticipants(predicate,1); b = players()
Expected:
  - b.length == a.length + 1; the single added id is exactly 'S'
  - the snapshot matches participants() (reads derived announced-set, no side register)
Status: [P]    (tests/design-24-polling-contract.test.js "should make players() respect consumption: a=players(); discoverParticipants(); b=players() differ by exactly the announced id (§8)")
```

```
S-024-08  consume-gated demotion keeps a departed id in players() until reported (§8)
Category: Polling contract, loud-fail and read API
Phase: B  Goal: participation
Preconditions:
  - seed 24, autoReleaseMode 'consume' peers A,B,C; C departs via partition
Actions:
  1. players() before consuming the report; getStoppedParticipating(); players() after
Expected:
  - C still appears in players() before consumption
  - getStoppedParticipating() includes 'C'; after consuming, players() drops 'C'
Status: [P]    (tests/design-24-polling-contract.test.js "should keep a departed id in players() until getStoppedParticipating() is consumed (§8 consume-gated demotion)")
```

```
S-024-09  per-tick handle: stale handle loud-fails, bare id re-resolves (§8)
Category: Polling contract, loud-fail and read API
Phase: B  Goal: participation
Preconditions:
  - seed 24, devMode peers A,B
Actions:
  1. handle = players() entry for B this tick; read via it
  2. Advance a tick; read via the now-stale handle
  3. Re-fetch the handle this tick via the bare id and read again
Expected:
  - Reading via a past-tick handle throws (revoked at tick boundary)
  - Re-fetching via the bare id resolves to the same logical input
Status: [P]    (tests/design-24-polling-contract.test.js "should DEV-LOUD-FAIL a stale (past-tick) handle while the bare id re-resolves identically (§8 per-tick handle)")
```

```
S-024-10  hashing a handle dev-throws "store the id instead" (§8)
Category: Polling contract, loud-fail and read API
Phase: B  Goal: participation
Preconditions:
  - seed 24, devMode peers A,B
Actions:
  1. handle = players() entry for B; hashValue({avatar: handle})
  2. hashValue({avatar: 'B'}) with the bare id instead
Expected:
  - Hashing state containing a handle throws a localized /handle/i error
  - Hashing state with the bare id hashes cleanly (replay-stable, serializable)
Status: [P]    (tests/design-24-polling-contract.test.js "should DEV-THROW when a capability handle leaks into hashed state (\"store the id instead\") (§8)")
```

### Category 25 — Retention/GC, self-input dropping (opt3) and spectator cost (design-spec; DESIGN_PARTICIPATION.md sections 9, 10, 12)
Covers §9's three-case finalized-floor retention invariant, §10 option-3 self-input dropping (send until the floor, stop only after a loss finalizes; surprise late promotion leaves no gap), and §12 spectator ≈0 cost. The 3 [P] scenarios pin the IMPLEMENTED finalized-floor snapshot GC (bounded plateau vs a GC-disabled control + pure-null spectator stores nothing); the 6 [T] scenarios are RED design-targets for participation-gated per-input retention, the `discoverable` flag, `isParticipant`/`getStoppedParticipating`, and self-input-drop accounting — all currently unimplemented.

```
S-025-01  Snapshot retention plateaus under continued simulation
Category: Retention/GC, self-input dropping (opt3) and spectator cost
Phase: B  Goal: participation
Preconditions:
  - seed=11 PeerHarness; A active (v=1 from tick 0), B passive; finalization (GC) ON
Actions:
  1. Run to tick 40, record retainedSnapshotCount().
  2. Run to tick 80, record retainedSnapshotCount().
Expected:
  - Snapshot count <20 at both checkpoints and EXACTLY equal at 40 and 80 (welded to the finalized floor, not per-tick accrual).
  - finalizationHorizon() advanced past 60 with the sim.
Status: [P]    (tests/design-25-retention-self-input.test.js "should plateau retainedSnapshotCount() under continued simulation (bounded growth, not per-tick accrual)")
```

```
S-025-02  Finalized-floor GC bounded vs a GC-disabled control that grows linearly
Category: Retention/GC, self-input dropping (opt3) and spectator cost
Phase: B  Goal: participation
Preconditions:
  - seed=22; two identical runs to tick 80, one with finalization ON, one with finalization OFF
Actions:
  1. Build the GC-ON peer; build the GC-OFF control peer.
  2. Compare retainedSnapshotCount() and finalizationHorizon().
Expected:
  - GC ON: count <20, finalizationHorizon() >60.
  - GC OFF: finalizationHorizon() == null, count >= tick (one snapshot per simulated tick) and >3x the GC-ON count — GC is doing real work.
Status: [P]    (tests/design-25-retention-self-input.test.js "should keep finalized-floor GC bounded while a GC-disabled control grows linearly with ticks")
```

```
S-025-03  Pure null-sending spectator stores zero candidate state
Category: Retention/GC, self-input dropping (opt3) and spectator cost
Phase: B  Goal: participation
Preconditions:
  - seed=33; A active (v=1), W1 and W2 pure spectators that only ever send null
Actions:
  1. Run to tick 40.
  2. Inspect A.participants(), A.inputChanges(W1/W2), and the watchers' messagesSent().
Expected:
  - A.participants() == ['A'] only; the two null watchers never materialize as participants.
  - inputChanges(W1)/inputChanges(W2) are empty; W1/W2 each sent 0 messages (sparseness => silence).
Status: [P]    (tests/design-25-retention-self-input.test.js "should store ZERO candidate state for a pure null-sending spectator (no decoder, absent from participants())")
```

```
S-025-04  Non-discoverable spectator arrow-input dropped on sight (≈0 stored)
Category: Retention/GC, self-input dropping (opt3) and spectator cost
Phase: B  Goal: participation
Preconditions:
  - seed=55; A active; S spams alternating v (left/right) but NEVER a discoverable input
Actions:
  1. Run to tick 40.
  2. Inspect A.inputChanges('S').length.
Expected:
  - DESIRED (§9/§12): a spectator's non-discoverable churn following no live discoverable is dropped on sight, so <=1 change stored. (RED: today the engine hoards all ~30 changes.)
Status: [P]    (tests/design-25-retention-self-input.test.js "should drop a non-discoverable spectator arrow-input on sight, storing ≈no candidate state (§9/§12)")
```

```
S-025-05  isParticipant() distinguishes membership from carrying an input array
Category: Retention/GC, self-input dropping (opt3) and spectator cost
Phase: B  Goal: participation
Preconditions:
  - seed=66; A active; S carries an input array (spectating) but never finalized a discoverable promotion
Actions:
  1. Run to tick 30.
  2. Call A.isParticipant('S') and A.isParticipant('A').
Expected:
  - DESIRED (§9): isParticipant is a function; isParticipant('S') == false (array != participant), isParticipant('A') == true. (RED: method absent.)
Status: [T]    (tests/design-25-retention-self-input.test.js "should expose isParticipant() so a pure spectator is NOT a participant despite carrying an input array (§9)")
```

```
S-025-06  Discoverable flag: retain only from the oldest live discoverable onward
Category: Retention/GC, self-input dropping (opt3) and spectator cost
Phase: B  Goal: participation
Preconditions:
  - seed=77; A active; J spectates (non-discoverable) ticks 0..4, emits a discoverable join at tick 5, then follow-ups
Actions:
  1. Run to tick 30.
  2. Inspect A.inputChanges('J').
Expected:
  - DESIRED (§9 case 2): nothing kept strictly before tick 5; the tick-5 change is retained and flagged discoverable. (RED: discoverParticipants/discoverable-aware retention absent.)
Status: [T]    (tests/design-25-retention-self-input.test.js "should accept a discoverable input flag and retain only from the oldest LIVE discoverable onward (§9 case 2)")
```

```
S-025-07  No pending attempt → full GC back to spectator
Category: Retention/GC, self-input dropping (opt3) and spectator cost
Phase: B  Goal: participation
Preconditions:
  - seed=88; A active; X churns ticks 0..1 then goes idle with NO pending discoverable
Actions:
  1. Run to tick 60 (ticks 0..1 finalize far below the floor).
  2. Inspect A.inputChanges('X') and A.getStoppedParticipating().
Expected:
  - DESIRED (§9 case 3): X's finalized-below-floor array is fully GC'd (empty) and X is reported departed-to-spectator. (RED: getStoppedParticipating absent.)
Status: [T]    (tests/design-25-retention-self-input.test.js "should GC a non-participant with NO pending attempt all the way back to spectator (§9 case 3)")
```

```
S-025-08  Option-3 self-input dropping: send up to the floor, stop only after a loss finalizes
Category: Retention/GC, self-input dropping (opt3) and spectator cost
Phase: B  Goal: participation
Preconditions:
  - seed=99; A an established participant; L grabs (discoverable) then sends endless follow-ups
Actions:
  1. Run to tick 60.
  2. Inspect L.selfInputDropTick() relative to L.finalizationHorizon() and L.messagesSent().
Expected:
  - DESIRED (§10 opt3): selfInputDropTick is a function returning >0 and <= finalizationHorizon() (it sent right up to the floor, never speculatively); messagesSent() >1. (RED: accounting hook absent.)
Status: [T]    (tests/design-25-retention-self-input.test.js "should keep transmitting follow-ups up to the finalized floor and stop only after a loss finalizes (§10 option 3)")
```

```
S-025-09  Surprise late promotion leaves NO input gap (sending never stopped while a win was possible)
Category: Retention/GC, self-input dropping (opt3) and spectator cost
Phase: B  Goal: participation
Preconditions:
  - seed=101; A active; L grabs (discoverable) + follow-ups; A disconnects mid-window freeing a contended slot so L promotes late
Actions:
  1. Run to tick 10, disconnect A, run to tick 50.
  2. Check L.isParticipant('L') and reconstructedValueAt('L', t) for every tick 1..40.
Expected:
  - DESIRED (§10 opt3): late promotion takes effect (isParticipant('L') true) and every tick up to promotion has a non-null reconstructed input — NO gap, because sending never stopped. (RED: membership/late-promotion absent.)
Status: [T]    (tests/design-25-retention-self-input.test.js "should leave NO input gap on a surprise late promotion because sending never stopped while a win was possible (§10 option 3)")
```

### Category 26 — Liveness in CONNECTED (multi-hop) networks via beat forwarding (design-spec; DESIGN_PARTICIPATION.md sections 6.1, 6.3)
Asserts the headline that B7 liveness must work on a CONNECTED (not just COMPLETE) graph: on A<->B<->C with no direct A-C link, A must learn C's disconnect at the same canonical tick as B because B forwards C's beats (grow-only-max gossip). The COMPLETE-graph agreement baseline passes today; the multi-hop forwarding, forwarded-proof-of-life roster, connected-graph convergence, dup/reorder grow-only-max, and post-heal relay pickup are RED design-targets (engine emits only its own beat and forwards nothing).

```
S-026-01  Complete-graph disconnect-tick agreement (baseline)
Category: Liveness in CONNECTED networks via beat forwarding
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed 1, A,B,C complete mesh, hb every 2 ticks, timeout 4 ticks
Actions:
  1. Advance through tick 10 so C's last beat is heard directly by A and B
  2. Partition C from both A and B; advance to tick 40
Expected:
  - A and B both compute canonicalDisconnectTick(C) = lastBeat(10)+timeout(4) = 14
  - queryDisconnected(C,13) is false; queryDisconnected(C,14) is true
Status: [P]    (tests/design-26-connected-liveness.test.js "should let all three peers agree on C's canonical disconnect tick on a COMPLETE graph")
```

```
S-026-02  Complete-graph state convergence (disconnect is a real sim event)
Category: Liveness in CONNECTED networks via beat forwarding
Phase: B  Goal: participation
Preconditions:
  - Same complete-mesh triangle, liveSumStep summing connected participants
Actions:
  1. Advance through tick 10; partition C away; advance to tick 40
Expected:
  - Both survivors converge to sum 94 (14*3 contributors then 26*2 after C drops at 14)
Status: [P]    (tests/design-26-connected-liveness.test.js "should converge survivors to identical state on a COMPLETE graph (disconnect is a real sim event)")
```

```
S-026-03  canonicalDisconnectTick exists and is null pre-timeout
Category: Liveness in CONNECTED networks via beat forwarding
Phase: B  Goal: participation
Preconditions:
  - Fresh triangle, no time advanced
Actions:
  1. Inspect A.canonicalDisconnectTick
Expected:
  - It is a function and returns null for C before any beat/timeout
Status: [P]    (tests/design-26-connected-liveness.test.js "should expose canonicalDisconnectTick as the implemented convergence primitive")
```

```
S-026-04  Multi-hop: A learns C's disconnect tick via B's forwarding
Category: Liveness in CONNECTED networks via beat forwarding
Phase: B  Goal: participation
Preconditions:
  - Connected line A<->B<->C; A-C severed (best-effort beats cannot cross); A-B and B-C healthy
Actions:
  1. Advance through tick 10 (B hears C directly; A only if B forwards)
  2. Partition C from B (C goes fully silent); advance to tick 40
Expected:
  - B computes canonicalDisconnectTick(C)=14
  - A also holds a numeric tick for C and AGREES on 14 (requires beat forwarding)
Status: [T]    (tests/design-26-connected-liveness.test.js "should let A learn C's disconnect at the SAME tick as B on A<->B<->C (B forwards C's beats)")
```

```
S-026-05  Forwarded proof-of-life makes C visible in A's roster
Category: Liveness in CONNECTED networks via beat forwarding
Phase: B  Goal: participation
Preconditions:
  - Connected line A<->B<->C; A-C severed
Actions:
  1. Advance ~20 ticks while C keeps beating to B
Expected:
  - A.participants() contains C (a forwarded beat is C's proof-of-life to A)
Status: [T]    (tests/design-26-connected-liveness.test.js "should make A treat C as a known participant on a CONNECTED graph (forwarded proof-of-life)")
```

```
S-026-06  Connected-graph survivor convergence (tick AND state)
Category: Liveness in CONNECTED networks via beat forwarding
Phase: B  Goal: participation
Preconditions:
  - Connected line A<->B<->C; A-C severed
Actions:
  1. Advance through tick 10; partition C from B; advance to tick 40
Expected:
  - A computes C's disconnect at 14 (from forwarded beats), getState() equals B's, sum 94
Status: [T]    (tests/design-26-connected-liveness.test.js "should converge survivors A and B to the SAME tick AND state on a CONNECTED graph")
```

```
S-026-07  Forwarded beats converge grow-only-max under dup+reorder
Category: Liveness in CONNECTED networks via beat forwarding
Phase: B  Goal: participation
Preconditions:
  - Connected line A<->B<->C; A-C severed; B->A relay leg lossy/jittery/duplicating (latency 40, jitter 120, dropRate 0.15, duplicateRate 0.4)
Actions:
  1. Advance 30 ticks while C beats through B's relay to A
Expected:
  - A's forwarded view of C equals B's direct max exactly (no double-count, no backward move) and never exceeds producer tick+timeout
Status: [T]    (tests/design-26-connected-liveness.test.js "should converge forwarded beats grow-only-max under duplication+reorder (no double-count, no backward move)")
```

```
S-026-08  Truly partitioned A stays honestly null (forwarding can't cross a real cut)
Category: Liveness in CONNECTED networks via beat forwarding
Phase: B  Goal: participation
Preconditions:
  - Triangle with A-C severed, then A-B severed too (A fully isolated); B-C healthy
Actions:
  1. Advance 30 ticks (C beats to B only)
  2. Restore A-B; advance to tick 60
Expected:
  - B does not consider C disconnected; A.canonicalDisconnectTick(C) is null while isolated (passes today)
  - After A-B heals, A picks up C's beats relayed via B and holds a numeric tick (requires forwarding)
Status: [T]    (tests/design-26-connected-liveness.test.js "should leave C's tick null on a TRULY partitioned A (honest limit — forwarding cannot cross a real cut)")
```

### Category 27 — Lossy/reordering/duplicating transport — input wire protocol (design-spec; DESIGN_PARTICIPATION.md §10.1)
Enabler 1 (reorder-tolerant decoder, exact-tick dedupe) is IMPLEMENTED in SparseInput.js — those unit + perfect-link harness scenarios PASS today. Enabler 2 (bounded last-N proactive redundancy + SACK fast-retransmit + TIME-based adaptive RTO) is now IMPLEMENTED too — OPT-IN via `inputRedundancy: N` (built change-tick-keyed rather than via a separate msgId stream, since the author is authoritative over its own log; see DESIGN_PARTICIPATION.md §10.1 "Implementation status"). All four convergence-under-loss scenarios are now GREEN (8/8). The lossy scenarios pass `inputRedundancy:3` + a grace window (`graceWindowMs:2000`) wider than the link RTT — a hole must be re-delivered before its tick finalizes. Envelope: 100% convergence at light loss, ~93% at 25% loss; the residual is the finalized-tail case (B8 recovery's remit).

```
S-027-01  Reorder-tolerant decoder converges for all 6 arrival orders
Category: Lossy/reordering/duplicating transport — input wire protocol
Phase: B  Goal: participation
Preconditions:
  - A fresh SparseInputDecoder('NONE') per permutation of three exact-tick changes (t10:A),(t20:B),(t30:A)
Actions:
  1. Apply the three changes in each of the 6 possible arrival orders.
  2. Read valueAt(9/10/19/20/29/30/999).
Expected:
  - Every order reconstructs the identical stream: NONE before t10, A on [10,19], B on [20,29], A from 30 on.
  - The load-bearing case: an out-of-order t30 must NOT erase the t20:B change.
Status: [P]    (tests/design-27-lossy-wire-protocol.test.js "should converge to the SAME stream for all 6 arrival orders of (t10:A),(t20:B),(t30:A)")
```

```
S-027-02  Duplicate delivery is idempotent (exact-tick dedupe)
Category: Lossy/reordering/duplicating transport — input wire protocol
Phase: B  Goal: participation
Preconditions:
  - A SparseInputDecoder('NONE'); the same change (10,'A') delivered three times (duplicateRate-style).
Actions:
  1. applyChange(10,'A') three times; inspect the returned {changed} and the stored change set.
Expected:
  - First apply reports changed:true; both repeats report changed:false (no rollback churn).
  - Exactly one stored change at tick 10; valueAt(10)==='A'.
Status: [P]    (tests/design-27-lossy-wire-protocol.test.js "should be idempotent under duplicate delivery (exact-tick dedupe, no-op on repeat)")
```

```
S-027-03  Reordered+duplicated stream hashes identically to the in-order stream
Category: Lossy/reordering/duplicating transport — input wire protocol
Phase: B  Goal: participation
Preconditions:
  - Two decoders: one fed [(10,A),(20,B),(30,A)] in order; one fed a reordered stream WITH duplicates.
Actions:
  1. Feed both; serialize each decoder's changes().
Expected:
  - The two change-sets are byte-identical (structural convergence — the property harness convergence relies on).
Status: [P]    (tests/design-27-lossy-wire-protocol.test.js "should produce an identical change-set hash for two decoders fed reordered+duplicated streams")
```

```
S-027-04  Perfect-link harness baseline: two engines converge on engineFingerprint
Category: Lossy/reordering/duplicating transport — input wire protocol
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed:1; default link latency 40, jitter/drop/dup all 0; engines A (changes v1/v2/v3) and B (v10/v20).
Actions:
  1. Run 60 ticks of 50ms.
Expected:
  - B.inputChanges('A') === A.inputChanges('A') and vice-versa; states equal; A.engineFingerprint()===B.engineFingerprint().
  - Isolates the lossy reds below as the only thing the §10.1 redundancy machinery must fix.
Status: [P]    (tests/design-27-lossy-wire-protocol.test.js "should converge two SimulationEngines on a PERFECT (no-loss) link — engineFingerprint agreement baseline")
```

```
S-027-05  Lossy+jitter link must leave no permanent input hole (end-to-end)
Category: Lossy/reordering/duplicating transport — input wire protocol
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed:7; default link latency 40, jitter 80, dropRate 0.25, duplicateRate 0.1 (both directions); active A and B.
Actions:
  1. Run 80 ticks of 50ms over the lossy+reordering link.
Expected:
  - Each peer reconstructs the other's full change stream (inputs are a LOG — no permanent hole); states + engineFingerprint converge.
  - RED today: dropped best-effort input changes are never retransmitted, so the streams diverge.
Status: [P]    (tests/design-27-lossy-wire-protocol.test.js "should reconstruct the SAME input stream end-to-end across a lossy+jitter link (no permanent hole)")
```

```
S-027-06  Bounded last-N (N=3) redundancy absorbs <=N-1 consecutive losses with ZERO extra RTT
Category: Lossy/reordering/duplicating transport — input wire protocol
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed:3; A emits a change ~every tick for 8 ticks; B passive. A->B dropRate forced to 1 for 2 consecutive change ticks, then healed.
Actions:
  1. Blackout A->B across ticks ~2..3 (two consecutive changes), restore, run to tick 30.
Expected:
  - B holds ALL of A's 8 changes purely from piggybacked last-N redundancy on the next message — no request, no wait.
  - RED today: there is no last-N redundancy on the wire, so the two dropped changes stay lost.
Status: [P]    (tests/design-27-lossy-wire-protocol.test.js "should absorb <= N-1 (N=3) CONSECUTIVE input-change losses with ZERO extra round-trip (proactive redundancy)")
```

```
S-027-07  A >=N burst reconciles via TIME-based RTO/SACK targeted resend
Category: Lossy/reordering/duplicating transport — input wire protocol
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed:11; A emits 10 changes; B passive. A->B dropRate forced to 1 across 4 consecutive changes (> N=3), then healed.
Actions:
  1. Blackout 4 consecutive A->B changes (outside the redundancy window), heal, run to tick 60 so RTO can fire.
Expected:
  - The hole the primary redundancy path cannot fill is reported via SACK and resent (ONLY the hole) after it is un-acked past RTO; streams + state converge.
  - RED today: no ack/SACK/RTO machinery exists; the >N hole is never filled.
Status: [P]    (tests/design-27-lossy-wire-protocol.test.js "should reconcile a >= N (>=3) burst loss via TIME-based RTO/SACK targeted resend")
```

```
S-027-08  Large-RTT (>N in flight, no loss) must NOT be mistaken for loss — trigger is TIME not COUNT
Category: Lossy/reordering/duplicating transport — input wire protocol
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed:5; default link latency 200 (4 ticks in flight > N=3), NO loss/jitter/dup; A emits 6 changes; B passive.
Actions:
  1. Run 60 ticks; assert convergence, then read A.resendCount().
Expected:
  - With no actual loss, an RTO (RTT-scaled, TIME) detector issues ZERO resends despite >N messages legitimately in flight; a COUNT-based detector would fire spuriously.
  - Convergence assertions PASS today; the test fails on the design guard because resendCount() is unimplemented (pins the count-vs-time intent).
Status: [P]    (tests/design-27-lossy-wire-protocol.test.js "should NOT mistake a large-RTT link (many msgs legitimately in flight) for loss — trigger is TIME not COUNT")
```

### Category 28 — Caps and floods / spectator scaling (design-spec; DESIGN_PARTICIPATION.md sections 3 discoverable flag / input-volume filtering, 11 caps and floods, 12 why-efficient: cheap spectators + conditional-join deletes the join-race)
Behaviors: node-count-independent memory/rollback plateau (PASS-today scale floor); input-volume filtering drops a sloppy non-discoverable spectator on sight; a discoverable joinGame FLOOD is only transient (limit=2 winners announced, losers finalize non-promoting); deterministic, rollback-recomputed join-race selection identical on every node; max_participants admission stops loser re-spam. The 3 baseline scenarios PASS; the 6 filtering/announce scenarios are RED design-targets (input-volume filtering and discoverParticipants/isParticipant are unimplemented).

```
S-028-01  Memory/rollback plateau is node-count-independent
Category: Caps and floods / spectator scaling
Phase: B  Goal: participation
Preconditions:
  - ScaleScenario: 1 active + N passive SimulationEngine peers, GC on, seed 28, 40 ticks
Actions:
  1. Run the scenario with passiveCount=4, then again with passiveCount=120.
  2. Read the active peer's retainedSnapshotCount + finalizationHorizon + rollbacks.
Expected:
  - Active peer's retained snapshot count is IDENTICAL at 4 and 120 passive peers.
  - Active peer's finalization horizon is IDENTICAL at both scales.
  - Retained snapshots stay tiny (<20) vs 40 simulated ticks; rollbacks==0 (passive watchers never send late inputs).
Status: [P]    (tests/design-28-caps-floods.test.js "should keep an active peer's retained snapshots + finalization horizon FLAT as passive spectators scale 4->40->120")
```

```
S-028-02  Passive spectators emit zero input bandwidth
Category: Caps and floods / spectator scaling
Phase: B  Goal: participation
Preconditions:
  - ScaleScenario: 1 active + 80 null-intent passive peers, seed 28, 40 ticks
Actions:
  1. Run scenario; sum every passive peer's MSG_INPUT (em-input) sends.
  2. Read the active peer's MSG_INPUT count.
Expected:
  - Total passive input messages == 0 (null intent is silence, B1 sparse).
  - Active peer's input volume is bounded by its CHANGES (<=5 over 40 ticks), not by the 80-node audience.
Status: [P]    (tests/design-28-caps-floods.test.js "should make purely-passive (null-intent) spectators emit ZERO input messages regardless of population")
```

```
S-028-03  Join-race winners computed locally, no negotiation chatter
Category: Caps and floods / spectator scaling
Phase: B  Goal: participation
Preconditions:
  - 6 peers (A..F) each press one discoverable joinGame at tick 0, seed 28
Actions:
  1. Advance 25 ticks.
  2. Read each racer's messagesSent().
Expected:
  - Each racer sent EXACTLY 1 message (its single sparse joinGame change) — no slot-negotiation/handshake traffic (the saved-handshake bonus of section 12.3).
Status: [P]    (tests/design-28-caps-floods.test.js "should compute promotion winners purely locally — NO extra messages from the losing flood (join-race is not negotiated)")
```

```
S-028-04  Sloppy non-discoverable spectator dropped on sight
Category: Caps and floods / spectator scaling
Phase: B  Goal: participation
Preconditions:
  - A presses discoverable joinGame; 12 watchers each spam a non-discoverable left, seed 28
Actions:
  1. Advance 25 ticks.
  2. On A, read inputChanges(watcher) and participants() for each watcher.
Expected:
  - Each watcher's stored input changes == [] (input-volume filtering drops non-discoverable spectator state on sight, section 3).
  - No watcher appears in participants() (a sloppy watcher must not enlarge the participant set, section 12.4).
  - The real participant A is still listed.
Status: [P]    (tests/design-28-caps-floods.test.js "should DROP a sloppy spectator's non-discoverable input on sight — never store it as candidate state (§3/§12.4)")
```

```
S-028-05  50-node sloppy flood adds zero candidate state
Category: Caps and floods / spectator scaling
Phase: B  Goal: participation
Preconditions:
  - Build A+joinGame with floodCount=0, then again with floodCount=50 sloppy non-discoverable pressers, seed 28
Actions:
  1. Advance 25 ticks in each build; compare A's participants().length and retainedSnapshotCount().
Expected:
  - Participant roster length is IDENTICAL with and without the 50-node flood (filtering => no blow-up).
  - Retained snapshot count is IDENTICAL with and without the flood (filtered flood stores nothing, section 12.4).
Status: [T]    (tests/design-28-caps-floods.test.js "should keep the active peer's memory IDENTICAL with vs without a 50-node sloppy flood (filtering => no blow-up, §12.4)")
```

```
S-028-06  Discoverable join flood announces only lowest-2; losers non-promoting
Category: Caps and floods / spectator scaling
Phase: B  Goal: participation
Preconditions:
  - 8 peers (J0..J7) all press discoverable joinGame, max_participants=2, seed 28
Actions:
  1. Advance 25 ticks.
  2. Call discoverParticipants(joinGame predicate, limit=2) and isParticipant on each id.
Expected:
  - Only the 2 lowest ids (J0,J1) are announced/admitted; both are participants.
  - The 6 losers (J2..J7) finalize NON-promoting and are not participants (section 11 transient flood).
Status: [T]    (tests/design-28-caps-floods.test.js "should announce only the lowest-2 of a discoverable JOIN FLOOD; losers finalize non-promoting (§11 transient flood)")
```

```
S-028-07  Flood winners identical on every node
Category: Caps and floods / spectator scaling
Phase: B  Goal: participation
Preconditions:
  - 5 racers (A..E) press discoverable joinGame; mixed link latencies (A/B->E 90ms, C->E 60ms), seed 28
Actions:
  1. Advance 30 ticks.
  2. Compute discoverParticipants(joinGame, 2) on EVERY node.
Expected:
  - Winner set == [A,B] on the first node, and EVERY node re-derives the identical set (deterministic, pure function of the finalized stream — section 12.3).
Status: [T]    (tests/design-28-caps-floods.test.js "should select the SAME flood winners on every node, re-derived from the finalized stream (deterministic join-race, §12.3)")
```

```
S-028-08  Late lower-id joinGame re-decides winner identically (rollback-recomputed)
Category: Caps and floods / spectator scaling
Phase: B  Goal: participation
Preconditions:
  - C,D join early; A's joinGame arrives via a 200ms-delayed A->C/A->D link into the non-finalized window; single slot, seed 28
Actions:
  1. Advance 35 ticks.
  2. Compute discoverParticipants(joinGame, 1) on C and on D.
Expected:
  - After the late A-join rolls the observers back, BOTH C and D re-derive the LOWER id A as the winner — not frozen by a drained pending queue (section 5 queue-pop trap / section 12.3 no special join desync).
Status: [T]    (tests/design-28-caps-floods.test.js "should re-decide flood winners IDENTICALLY when a late lower-id joinGame lands in the non-finalized window (rollback-recomputed, §5/§12.3)")
```

```
S-028-09  max_participants admission stops loser re-spam
Category: Caps and floods / spectator scaling
Phase: B  Goal: participation
Preconditions:
  - A,B press joinGame; L re-presses joinGame on multiple ticks (re-spam); max_participants=2, seed 28
Actions:
  1. Advance 30 ticks.
  2. Fill slots via discoverParticipants(joinGame, 2); check isParticipant('L').
Expected:
  - The 2 lowest ids (A,B) win; once the roster is full, L's repeated joinGame is inadmissible — no re-spam promotion (section 11 admission gate).
Status: [T]    (tests/design-28-caps-floods.test.js "should stop losers re-spamming once the roster is full — max_participants admission gate (§11)")
```

### Category 29 — Beat + wire-protocol fine mechanics (design-spec; DESIGN_PARTICIPATION.md §6.1, §6.2, §10.1)
Round-2 gap-closing set: the FINE attendance-wire + input-wire residual mechanics round 1 (cat26/27 headlines) left untested. **IMPLEMENTED — all 11 scenarios now `[P]`:** §6.2 dev-assert (a livenessTimeout < 2× the attendance interval throws in `devMode`); discoverable-as-first-beat proof-of-life (a discoverable input seeds the source's first beat — but NOT a `stopParticipating`, which is proof of death); `shouldForwardBeat(id)` forward-relevance predicate (participant or live-discoverable; exposed but not yet wired into the forward path, to avoid regressing the connected-liveness tests whose players don't announce); the value-keyed `beatRepeatState` ×3 counter (armed on a beat advance, decremented per outbound message, always keyed to the newest beat); `beatDeltaFor`/`lastAckIdFrom` (omit unchanged peers); `sendReasons()` (no send is ever 'beat-early'); and a monotonic `seq` on every outbound message + `sendBufferFor` (null for a pure spectator). Reconciled: proof-of-life test drops J's attendance so the discoverable is the sole beat; forward-gating test announces A/B.

```
S-029-01  Grow-only-max discards a stale beat
Category: Beat + wire-protocol fine mechanics
Phase: B  Goal: liveness
Preconditions:
  - A DisconnectTracker with timeoutTicks=4 has learned C@50 (disconnect tick 54).
Actions:
  1. Note a LOWER beat C@30 (stale/reordered).
  2. Note an EQUAL beat C@50 (a legitimate value-keyed re-stamp).
  3. Note a strictly-NEWER beat C@60.
Expected:
  - The stale and equal beats are no-ops: lastAttendanceTick stays 50, canonical tick stays 54 (never moves backward).
  - The newer beat shifts the record forward to canonical tick 64.
Status: [P]    (tests/design-29-beat-wire-mechanics.test.js "should DISCARD a lower/stale beat learned after a higher one (grow-only-max, never moves backward)")
```

```
S-029-02  Own-beat rides the interval floor in a quiet stretch
Category: Beat + wire-protocol fine mechanics
Phase: B  Goal: liveness
Preconditions:
  - A solo engine A (attendance on, interval 2 ticks) whose intent holds {v:1} after tick 0 (no input traffic after tick 0).
Actions:
  1. Advance to tick 4, record messagesSent.
  2. Advance to tick 20, record messagesSent again.
Expected:
  - messagesSent grows across the quiet stretch with NO new input changes — the growth is the interval-floor own-beat.
  - The delta is at least ~6 over 16 quiet ticks (one beat per 2-tick interval).
Status: [P]    (tests/design-29-beat-wire-mechanics.test.js "should grow own-beat emission on the interval FLOOR in a quiet stretch (messagesSent climbs)")
```

```
S-029-03  Own beat is the current tick, re-stamped each interval
Category: Beat + wire-protocol fine mechanics
Phase: B  Goal: liveness
Preconditions:
  - Two engines A,B on a complete link, both beating every 2 ticks.
Actions:
  1. Advance 20 ticks.
  2. Read B's tracked last beat for A (canonicalDisconnectTick('A') - timeout).
Expected:
  - B's record of A's last beat is recent (>= tick 16), not pinned to A's tick-0 value — A re-stamps its CURRENT tick, never repeats a stale own beat.
Status: [P]    (tests/design-29-beat-wire-mechanics.test.js "should re-stamp own CURRENT tick as its beat each interval (own beat == own tick, never an old value)")
```

```
S-029-04  Value-keyed x3 repeat counter, reset on a newer beat
Category: Beat + wire-protocol fine mechanics
Phase: B  Goal: liveness
Preconditions:
  - Two engines A,B; B has learned A's advancing beat over 10 ticks.
Actions:
  1. Inspect B.beatRepeatState('A').
Expected:
  - A per-player armed counter in [0,3] keyed to A's CURRENT (max) beat value exists; switching to a newer value resets it to 3 and an OLD beat is never re-sent.
Status: [P]    (tests/design-29-beat-wire-mechanics.test.js "should expose a VALUE-keyed ×3 repeat counter that resets to 3 on a newer beat mid-burst (never re-sends an old beat)")
```

```
S-029-05  Beats are delta'd against the recipient ackId
Category: Beat + wire-protocol fine mechanics
Phase: B  Goal: liveness
Preconditions:
  - Three engines A,B,C all beating; B/C beats unchanged since A's last ack to B.
Actions:
  1. Compute A.beatDeltaFor('B', ackId).
Expected:
  - The delta omits unchanged peers (e.g. C is absent); only entries that INCREASED since the recipient ackId are sent, not a full snapshot.
Status: [P]    (tests/design-29-beat-wire-mechanics.test.js "should send beats DELTA-against the recipient ackId, not the full snapshot")
```

```
S-029-06  Beats never trigger an early send
Category: Beat + wire-protocol fine mechanics
Phase: B  Goal: liveness
Preconditions:
  - Engine A in a quiet stretch (intent held); peer B's beat advances rapidly.
Actions:
  1. Record A.messagesSent before and after a 6-tick window where A has no urgent payload.
  2. Inspect A.sendReasons().
Expected:
  - A emits EXACTLY 3 sends over the 6-tick window (3 interval beats) — learning B's beat does NOT cause an out-of-band send; zero 'beat-early' send reasons.
Status: [P]    (tests/design-29-beat-wire-mechanics.test.js "should NEVER let a beat trigger an EARLY send — beats only ride existing traffic / the interval floor")
```

```
S-029-07  A discoverable input is the first beat (proof-of-life)
Category: Beat + wire-protocol fine mechanics
Phase: B  Goal: participation
Preconditions:
  - Engine A; engine J emits a single discoverable join input at tick 0, then is quiet (no dedicated attendance yet).
Actions:
  1. Advance 4 ticks.
  2. Read A.canonicalDisconnectTick('J').
Expected:
  - A holds a beat for J derived from the discoverable input itself (tick 0 + timeout 4 = 4) — the discoverable flag seeds proof-of-life with no separate attendance.
Status: [P]    (tests/design-29-beat-wire-mechanics.test.js "should make a discoverable input the FIRST beat (proof-of-life on join), via a discoverable flag")
```

```
S-029-08  A leaver beats until it finalizes as a non-participant
Category: Beat + wire-protocol fine mechanics
Phase: B  Goal: participation
Preconditions:
  - Engine A; engine L participating, then calls stopParticipating at tick 6.
Actions:
  1. Advance ticks 7..9 (stop signal sent but not yet finalized).
  2. Check A.queryDisconnected('L', A.tick-1).
Expected:
  - L still beats and A sees L as live (not disconnected) until the stop finalizes — not the instant the stop is sent (the stop must propagate and roll others back first).
Status: [P]    (tests/design-29-beat-wire-mechanics.test.js "should keep a leaver BEATING until it finalizes as a NON-participant, not the instant it sends a stop")
```

```
S-029-09  A pure spectator's beat is not forwarded
Category: Beat + wire-protocol fine mechanics
Phase: B  Goal: liveness
Preconditions:
  - A<->B<->C-style mesh; S is a pure spectator (no discoverable input) reachable from A only via B.
Actions:
  1. Advance 16 ticks.
  2. Inspect B.shouldForwardBeat('A') and B.shouldForwardBeat('S').
Expected:
  - B forwards a participant's beat (A => true) but NOT the pure spectator's (S => false) — forward-gating is relevance-filtered to participant / live-discoverable players.
Status: [P]    (tests/design-29-beat-wire-mechanics.test.js "should NOT forward a pure SPECTATOR beat (forward only relevant: participant / live-discoverable)")
```

```
S-029-10  Dev-mode rejects a too-tight timeout (load-bearing precondition)
Category: Beat + wire-protocol fine mechanics
Phase: B  Goal: liveness
Preconditions:
  - Construct an engine in dev mode with attendanceIntervalMs=100 and livenessTimeoutMs=100 (timeout = ONE interval, < k intervals).
Actions:
  1. Attempt construction.
Expected:
  - Construction THROWS loudly (§6.2: timeoutTicks must span SEVERAL gossip intervals; a tighter timeout is a misconfiguration, not papered over with a probe).
Status: [P]    (tests/design-29-beat-wire-mechanics.test.js "should FAIL LOUDLY in dev mode when timeoutTicks < k·attendanceInterval (§6.2 load-bearing precondition)")
```

```
S-029-11  Monotonic message-id + relevance-gated send buffer (no buffer for a spectator)
Category: Beat + wire-protocol fine mechanics
Phase: B  Goal: wire-protocol
Preconditions:
  - Engine A emitting changing inputs; S a pure spectator. A's transport is tapped to record per-message seq.
Actions:
  1. Advance 12 ticks, collecting every outbound seq.
  2. Inspect A.sendBufferFor('S').
Expected:
  - Each outbound message carries a strictly increasing monotonic seq/message-id.
  - A retains NO send buffer aimed at the pure spectator S (relevance-gated: a spectator never acks).
Status: [P]    (tests/design-29-beat-wire-mechanics.test.js "should carry a MONOTONIC message id/seq on each outbound message, and NOT retain a send buffer for a pure spectator (§10.1 residuals)")
```

### Category 30 — Input delay D and self-input timing (design-spec; DESIGN_PARTICIPATION.md sections 10, 12.2)
Round-2 gap-closing set. Round 1 never tested input delay `D`, which is foundational to the self-input story. Covers: D=0 ground-truth stamping (input lands at T, not T+D) and the rollback cost a high-latency link incurs (what D exists to delete). **IMPLEMENTED — opt-in `inputDelay` ticks (default 0); all 7 scenarios now `[P]`** (stamp-at-T+D + immediate broadcast, rollback reduction from D, the §12.2 near-zero-rollback quantification `D ≥ RTT → ≤1 rollback`, and shared delay D for discoverable inputs). The local sample shifts only the outbound stamp to T+D (encoder still monotonic); a future-stamped change is accepted at the remote (negative age) and consumed without rollback. The former "silently ignores inputDelay" guard was flipped to "HONORS inputDelay" (inverse regression guard).

```
S-030-01  Self-input stamps at the current tick T today (D=0 ground truth)
Category: Input delay D and self-input timing
Phase: B  Goal: participation
Preconditions:
  - Peer A scripted passive for ticks 0,1 then presses v=1 at sim-tick 2; spectator B; seed 30
Actions:
  1. Advance 12 ticks (TICK_MS=50, 20 TPS).
  2. Read A.inputChanges('A').
Expected:
  - Exactly one sparse change, stamped {tick:2, intent:{v:1}} — the tick the sim was AT, with NO +D shift (no input delay implemented).
Status: [P]    (tests/design-30-input-delay.test.js "should stamp a self-input for the CURRENT sim tick T (no +D shift) today (D=0 ground truth)")
```
```
S-030-02  High-latency conflicting inputs incur many rollbacks (the cost D removes)
Category: Input delay D and self-input timing
Phase: B  Goal: participation
Preconditions:
  - Default link latency 200ms (4 ticks in flight), no loss/jitter; A and B each emit a changing v=i%3 / (i+1)%3 stream; seed 30
Actions:
  1. Advance 30 ticks.
  2. Read rollbackCount() on both peers.
Expected:
  - Each peer rewinds repeatedly for late remote changes — rollbackCount > 5 on both. This is the §12.2 baseline input delay attacks.
Status: [P]    (tests/design-30-input-delay.test.js "should incur MANY rollbacks on a high-latency link with conflicting inputs (the cost D removes)")
```
```
S-030-03  Unknown inputDelay option is silently ignored today
Category: Input delay D and self-input timing
Phase: B  Goal: participation
Preconditions:
  - Peer A constructed with inputDelay:5, pressing v=1 at sim-tick 2; seed 999
Actions:
  1. Advance 12 ticks.
  2. Check whether the change is stamped at 2+5=7.
Expected:
  - Constructor neither throws nor shifts the stamp — the change is still at tick 2, so honorsInputDelay(5) is false. Documents why the reds are red.
Status: [P]    (tests/design-30-input-delay.test.js "should silently IGNORE an unknown inputDelay option today (constructor does not honor it)")
```
```
S-030-04  Input produced at T is stamped for T+D and broadcast immediately
Category: Input delay D and self-input timing
Phase: B  Goal: participation
Preconditions:
  - D=5; A presses v=1 at sim-tick 2; spectator B; seed 30
Actions:
  1. Advance 15 ticks.
  2. Read A.inputChanges('A')[0].tick and B.reconstructedValueAt('A', 7).
Expected:
  - The change is stamped at T+D = 7 (not 2), and the remote B already has {v:1} at tick 7 (immediate broadcast). RED: inputDelay unimplemented, stamp stays at 2.
Status: [P]    (tests/design-30-input-delay.test.js "should stamp a self-input produced at tick T for tick T+D and broadcast it immediately (§10)")
```
```
S-030-05  Delay D reduces rollbacks vs D=0 over the same latency link
Category: Input delay D and self-input timing
Phase: B  Goal: participation
Preconditions:
  - Two runs, identical 100ms-latency link + conflicting v-streams, seed 31: control D=0, treatment D=5
Actions:
  1. Advance both 30 ticks.
  2. Compare A.rollbackCount() between the runs.
Expected:
  - D=5 strictly fewer rollbacks than the D=0 baseline (inputs arrive before needed). RED: inputDelay ignored, counts equal.
Status: [P]    (tests/design-30-input-delay.test.js "should reduce rollbacks vs D=0 over the SAME latency link (D buys delivery slack, §12.2)")
```
```
S-030-06  D >> RTT-in-ticks drives rollbacks toward zero (§12.2 quantified)
Category: Input delay D and self-input timing
Phase: B  Goal: participation
Preconditions:
  - 50ms latency (1 tick of travel), D=5 on both peers, conflicting v=i%4 / (i+2)%4 streams; seed 32
Actions:
  1. Advance 30 ticks.
  2. Read A.rollbackCount().
Expected:
  - With 5 ticks of slack >> 1-tick RTT almost nothing lands late → rollbackCount <= 1. RED today (29 rollbacks; inputDelay unimplemented).
Status: [P]    (tests/design-30-input-delay.test.js "should drive rollbacks toward ZERO when D exceeds the link RTT in ticks (§12.2 quantified)")
```
```
S-030-07  Discoverable inputs share the same delay D — no gap before first gameplay input
Category: Input delay D and self-input timing
Phase: B  Goal: participation
Preconditions:
  - D=4; A presses discoverable joinGame at sim-tick 1 then discoverable play v=2 at sim-tick 2; spectator B; seed 33
Actions:
  1. Advance 15 ticks.
  2. Read the two stamps in A.inputChanges('A').
Expected:
  - Both shifted by D: join at 1+D=5, first gameplay at 2+D=6, adjacency (gap 1) preserved — no input gap. RED: stamps stay at 1 and 2.
Status: [P]    (tests/design-30-input-delay.test.js "should apply the SAME delay D to a discoverable input — no gap before the delayed first input (§10)")
```

### Category 31 — Membership x liveness quadrants and reconnect nuances (design-spec; DESIGN_PARTICIPATION.md sections 1, 6)
Round-2 gap-closing set: pins the (isParticipant x isDisconnected) 2x2 quadrants and reconnect nuances that round-1 cat23 left untested (cat23 only covered Flag1/Flag2 individually). Covers all-four-quadrants distinctness, THE MISLABEL BUG (connected spectator must not be isDisconnected), the derivable all-dropped/stop-the-game predicate, idle/silent reconnect via attendance, the two agreed reconnect sub-moments, reservation-slot-not-given-away, and id-pinned re-admit. The first 3 are [P] (verified pass on the implemented B7 substrate: participants/queryDisconnected/canonicalDisconnectTick); the remaining 7 are [T] RED design-targets needing isParticipant/isDisconnected/discoverParticipants/reconnectTick/firstLiveInputTick/allParticipantsDropped.

```
S-031-01  Blipped participant stays announced yet flagged disconnected
Category: Membership x liveness quadrants
Phase: B  Goal: participation
Preconditions:
  - A,B,C attending; C partitioned from A and B at ~tick 11 (canonical disconnect tick 14)
Actions:
  1. Advance through tick 40
  2. Inspect A's participants() and queryDisconnected('C', 40)
Expected:
  - A.participants() still contains 'C' (slot held / reserved)
  - A.queryDisconnected('C', 40) is true (only the liveness bit flipped)
Status: [P]    (tests/design-31-liveness-quadrants.test.js "should keep a blipped participant ANNOUNCED while flagging it disconnected (the disconnected-participant quadrant, raw)")
```

```
S-031-02  Survivors agree on disconnect tick and announced set (quadrant determinism floor)
Category: Membership x liveness quadrants
Phase: B  Goal: participation
Preconditions:
  - A,B,C attending; C blips at ~tick 11
Actions:
  1. Advance through tick 40
  2. Compare canonicalDisconnectTick('C'), participants(), queryDisconnected on A and B
Expected:
  - A and B both compute canonicalDisconnectTick('C') = 14
  - A.participants() equals B.participants()
  - queryDisconnected('C',13)=false and queryDisconnected('C',14)=true on both
Status: [P]    (tests/design-31-liveness-quadrants.test.js "should agree across survivors on the canonical disconnect tick and the still-announced set (per-quadrant determinism floor)")
```

```
S-031-03  Never-heard id is not disconnected (passivity, not disconnect)
Category: Membership x liveness quadrants
Phase: B  Goal: participation
Preconditions:
  - A,B,C attending; C blips
Actions:
  1. Advance through tick 40
  2. Query A.queryDisconnected('Z',40) and A.participants() for an untracked id 'Z'
Expected:
  - queryDisconnected('Z',40) is false (never-heard = passivity, the substrate for the mislabel guard)
  - participants() does not contain 'Z'
Status: [P]    (tests/design-31-liveness-quadrants.test.js "should track liveness for a participant but report a never-heard id as NOT disconnected (passivity != disconnect — substrate of the mislabel guard)")
```

```
S-031-04  All four membership x liveness quadrants are distinct and correctly labelled
Category: Membership x liveness quadrants
Phase: B  Goal: participation
Preconditions:
  - A,B,C participants + S a connected spectator (discoverable:false); Flag1 OFF; C blipped
Actions:
  1. Advance through tick 40
  2. Read isParticipant x isDisconnected for A (active), C (blip), S (spectator), and a ghost id
Expected:
  - A => (participant, connected) = active
  - C => (participant, disconnected) = blip/reserved
  - S => (non-participant, connected) = spectator
  - ghost => (non-participant, not-tracked-disconnected) = gone
  - the three observable labels (A, C, S) are pairwise distinct
Status: [T]    (tests/design-31-liveness-quadrants.test.js "(a) should expose all four (isParticipant x isDisconnected) quadrants as DISTINCT, correctly-labelled states")
```

```
S-031-05  Mislabel bug: a connected spectator must not report isDisconnected
Category: Membership x liveness quadrants
Phase: B  Goal: participation
Preconditions:
  - A,B participants + S a connected non-participant spectator; all connected the whole run
Actions:
  1. Advance through tick 30 (no partitions)
  2. Read A.isParticipant('S') and A.isDisconnected('S')
Expected:
  - isParticipant('S') is false (it is a non-participant)
  - isDisconnected('S') is false — the bug to catch is collapsing isDisconnected into !isParticipant
Status: [T]    (tests/design-31-liveness-quadrants.test.js "(b) THE MISLABEL BUG: should NOT report a CONNECTED spectator/voluntary-leaver as isDisconnected (liveness is participant-gated)")
```

```
S-031-06  All-dropped (stop-the-game) condition derivable across the announced set
Category: Membership x liveness quadrants
Phase: B  Goal: participation
Preconditions:
  - A,B,C participants; B and C both partitioned from A at ~tick 11
Actions:
  1. Advance through tick 40
  2. Call A.allParticipantsDropped(); compute every other announced participant's isDisconnected
Expected:
  - allParticipantsDropped() is false (A itself is still alive)
  - every announced participant other than A is isDisconnected (the "only I am left" signal)
Status: [T]    (tests/design-31-liveness-quadrants.test.js "(c) should derive the all-dropped (stop-the-game) condition across the still-announced participant set")
```

```
S-031-07  Idle/silent reconnect flips isDisconnected back to false via attendance
Category: Membership x liveness quadrants
Phase: B  Goal: participation
Preconditions:
  - A,B,C participants; Flag1 OFF; C blips at ~tick 11, presses no new game input after returning
Actions:
  1. Heal C to A and B at tick ~20; advance through tick 45 (C beats but sends no input change)
  2. Read A.isParticipant('C') and A.isDisconnected('C')
Expected:
  - isParticipant('C') stays true (never un-admitted under reservation)
  - isDisconnected('C') flips false purely from resumed attendance (liveness rides attendance, not input)
Status: [T]    (tests/design-31-liveness-quadrants.test.js "(d) IDLE/SILENT reconnect: should flip isDisconnected back to false on a reconnected-but-SILENT participant (liveness rides attendance, not input)")
```

```
S-031-08  Two agreed reconnect sub-moments are distinguishable
Category: Membership x liveness quadrants
Phase: B  Goal: participation
Preconditions:
  - A,B,C; Flag1 OFF; C blips, reconnects silent, then presses a fresh input at tick 32
Actions:
  1. Heal C at tick ~20; advance through tick 45
  2. Read A.reconnectTick('C') and A.firstLiveInputTick('C'); compare reconnectTick on A vs B
Expected:
  - reconnectTick (IsDisconnected->false, "reconnecting") strictly precedes firstLiveInputTick ("active")
  - both are numbers; reconnectTick agrees across survivors A and B (deterministic, agreed tick)
Status: [T]    (tests/design-31-liveness-quadrants.test.js "(e) should distinguish the two agreed reconnect sub-moments: isDisconnected->false (network restored) vs first accepted live input (re-synced)")
```

```
S-031-09  Reserved slot not given away to a new joiner during a blip
Category: Membership x liveness quadrants
Phase: B  Goal: participation
Preconditions:
  - A,B,C participants (maxParticipants 3); Flag1 OFF; C blipped (slot held); D joins mid-blip
Actions:
  1. D presses a discoverable joinGame during C's blip; advance through tick 40
  2. A.discoverParticipants(input => input.joinGame === true, 3)
Expected:
  - admitted set does not contain 'D'; isParticipant('D') is false
  - isParticipant('C') is true — discoverParticipants fills only genuinely-empty slots
Status: [T]    (tests/design-31-liveness-quadrants.test.js "(f) RESERVATION: should NOT give C held slot to a new joiner D during the blip (discoverParticipants fills only genuinely-empty slots)")
```

```
S-031-10  Id-pinned re-admit under Flag1 ON re-admits exactly C
Category: Membership x liveness quadrants
Phase: B  Goal: participation
Preconditions:
  - A,B,C participants; Flag1 ON (disconnect=leave); C leaves at disconnect tick, then reconnects and presses a discoverable rejoin; D also presses joinGame
Actions:
  1. Heal C at tick ~20; advance through tick 45
  2. A.discoverParticipants((ctx,i)=>i.playerId==='C' && i.joinGame===true, 1)
Expected:
  - admitted equals exactly ['C']; isParticipant('C') is true
  - isParticipant('D') is false (the id-pin rejects D)
Status: [T]    (tests/design-31-liveness-quadrants.test.js "(g) id-pinned re-admit under Flag1 ON: should re-admit EXACTLY C via discoverParticipants((ctx,i)=>i.playerId===Cid && eligible, 1)")
```

### Category 32 — Retention/GC fine-grain and avatar persistence (design-spec; DESIGN_PARTICIPATION.md sections 1, 9)
Round-2 gap-closing set. Covers the fine-grained §1/§9 sub-claims round-1 cat25 skipped: avatar persists in HASHED state when the input array/snapshot is GC'd (PASS-today baselines), plus the case-1 retained-span==non-finalized-window claim, the logical-release-vs-physical-byte-GC split, the no-hoard of the promoting discoverable, and engineFingerprint insensitivity to dropping a dead spectator segment (all RED design-targets). [P]=verified passing, [T]=executable but RED.

```
S-032-01  Avatar survives snapshot-GC of the grab tick
Category: Retention/GC fine-grain and avatar persistence
Phase: B  Goal: participation
Preconditions:
  - Seeded PeerHarness (seed 1); A grabs the ball at tick 0 then holds (sparse), B observes; grace=6 ticks
Actions:
  1. Run to tick 80 (far past grace, so the tick-0 snapshot is finalized-floor GC'd)
Expected:
  - getState() on A still shows { holder: 'A' } — the avatar rides forward in finalized STATE, not the tick-0 input
  - retainedSnapshotCount() < 20 and finalizationHorizon() > 60 (proof the tick-0 snapshot was GC'd)
Status: [P]    (tests/design-32-retention-finegrain.test.js "should keep the avatar (ball-held-by-A) in getState() long after the grab tick is GC-finalized (§1)")
```

```
S-032-02  Avatar agrees on a node that never authored the input
Category: Retention/GC fine-grain and avatar persistence
Phase: B  Goal: participation
Preconditions:
  - Seeded PeerHarness (seed 2); A grabs at tick 0, B is a pure observer that sends nothing
Actions:
  1. Run to tick 80
Expected:
  - Both A.getState() and B.getState() equal { holder: 'A' } — the avatar (hashed STATE) is identical across nodes
Status: [P]    (tests/design-32-retention-finegrain.test.js "should preserve the avatar in getState() identically on a node that never re-derives it from a live input (§1 cross-node)")
```

```
S-032-03  Avatar lives in the hashed-STATE component of engineFingerprint
Category: Retention/GC fine-grain and avatar persistence
Phase: B  Goal: participation
Preconditions:
  - Seeded PeerHarness (seed 3); A holds the ball, B observes
Actions:
  1. Run to tick 80; read A.engineFingerprint() = "<input-log>@<tick>#<hashed-state>"
Expected:
  - The "#..." hashed-state suffix contains the avatar (holder/A) — the ball is in hashed game state, the thing that survives an array drop per §1
Status: [P]    (tests/design-32-retention-finegrain.test.js "should expose the avatar in the hashed-state component of engineFingerprint, distinct from the raw input log (§1/§9)")
```

```
S-032-04  Case-1: retained input span equals the non-finalized window
Category: Retention/GC fine-grain and avatar persistence
Phase: B  Goal: participation
Preconditions:
  - Seeded PeerHarness (seed 11); A changes its intent EVERY tick (each move is a real change), B observes; grace=6
Actions:
  1. Run to tick 60; read finalizationHorizon() and the retained inputChanges('A') ticks
Expected:
  - The minimum retained change tick is >= horizon-1 (finalized-below-floor inputs folded into baseline + byte-GC'd)
  - The retained span length <= non-finalized window width, NOT the whole run
  - (RED: today the decoder keeps the ENTIRE history from tick 0; arrays are never physically pruned)
Status: [P]    (tests/design-32-retention-finegrain.test.js "should retain a finalized participant's input span EQUAL to the non-finalized window, not the whole history (§9 case 1)")
```

```
S-032-05  Logical release at non-finalized T vs physical byte-GC at grace
Category: Retention/GC fine-grain and avatar persistence
Phase: B  Goal: participation
Preconditions:
  - Seeded PeerHarness (seed 22); A active, X churns then is a departure candidate; grace=6
Actions:
  1. Run to tick 30; pick a still-non-finalized tick T = tick-1
Expected:
  - getStoppedParticipating() exists and reports X as departed (logical release re-derivable at T)
  - X's array bytes for the non-finalized window still exist (rollback to before T must re-sim)
  - (RED: getStoppedParticipating() is absent; only the physical-persistence half is observable today)
Status: [P]    (tests/design-32-retention-finegrain.test.js "should release a participant LOGICALLY at a still-non-finalized tick T while the array bytes persist until grace (§9 logical/physical split)")
```

```
S-032-06  No-hoard: promoting discoverable not kept after it finalizes
Category: Retention/GC fine-grain and avatar persistence
Phase: B  Goal: participation
Preconditions:
  - Seeded PeerHarness (seed 33); J emits a discoverable grab at tick 0 then ordinary follow-ups
Actions:
  1. Run to tick 60 so the tick-0 discoverable finalizes far below the floor
Expected:
  - isParticipant('J') is still true — identity rides in the finalized announced-set baseline
  - inputChanges('J') no longer contains the discoverable grab (not hoarded after finalization)
  - (RED: isParticipant() and the discoverable flag / baseline machinery are unimplemented)
Status: [P]    (tests/design-32-retention-finegrain.test.js "should NOT hoard the promoting discoverable after it finalizes — identity rides in the finalized baseline (§9 no-hoard)")
```

```
S-032-07  Sender/receiver symmetry: dropping a dead segment is engineFingerprint-neutral
Category: Retention/GC fine-grain and avatar persistence
Phase: B  Goal: participation
Preconditions:
  - Two identical seeded runs (seed 44); A holds the ball, spectator S churns then goes silent (never grabs)
Actions:
  1. Run both to tick 80; on one node clear S's finalized input changes (model a node that GC'd the dead segment)
Expected:
  - getState() (the hashed avatar) is identical on both — dropping S's dead inputs cannot move the ball
  - engineFingerprint() is identical on both whether or not S's segment was dropped (STATE hashed, raw inputs not)
  - (RED: today engineFingerprint embeds the raw per-player input log, so clearing S's changes changes the hash → S=[] vs full log)
Status: [P]    (tests/design-32-retention-finegrain.test.js "should agree on engineFingerprint whether or not a dead spectator segment was dropped — STATE hashed, raw inputs not (§9 symmetry)")
```

### Category 33 — Announce/denounce determinism under rollback and the flag matrix (design-spec; DESIGN_PARTICIPATION.md §2.1, §2.2, §4, §5, §6, §15-tests-2&3)
Covers the fine-grained announce/denounce mechanics round 1 left untested: leave-then-refill from existing candidates, getStoppedParticipating<->participants()-diff equivalence, the 2x2 autoRelease flag matrix as COMBINATIONS, consume-gated idempotence across a rollback, and the agreed reactivation tick under skewed observations. Two PASS-today baselines pin the derived-membership substrate (participants() re-derives identically across a plain rollback); the other five are RED design-targets (need discoverParticipants/getStoppedParticipating/stopParticipating/isParticipant/isDisconnected/reactivationTick/the autoRelease flags). This is the round-2 gap-closing set.

```
S-033-01  participants() re-derives identically after a late-input rollback
Category: Announce/denounce determinism under rollback and the flag matrix
Phase: B  Goal: participation
Preconditions:
  - PeerHarness seed=33; A sends {v:1} from tick 0; B silent until tick 5 then {v:2}; B->A link latency=200ms (4 ticks)
Actions:
  1. Run 30 ticks. B's late change lands in A's non-finalized window, forcing A to roll back.
Expected:
  - A.rollbackCount() > 0 (the rollback really fired)
  - A.participants() === ['A','B'] and equals B.participants() (derived membership re-ran identically)
  - A.getState() === B.getState() (both converged)
Status: [P]    (tests/design-33-denounce-rollback-matrix.test.js "should re-derive an IDENTICAL participant set on both nodes AFTER a late input forces a rollback")
```

```
S-033-02  derived membership is seed-independent (not arrival-order dependent)
Category: Announce/denounce determinism under rollback and the flag matrix
Phase: B  Goal: participation
Preconditions:
  - Same scenario built twice with different RNG seeds (101, 202); B->A latency=200ms
Actions:
  1. Run each 30 ticks; read A.participants().
Expected:
  - Both seeds yield ['A','B'] — membership is a pure function of the finalized stream, not jitter/reception order (§2.1).
Status: [P]    (tests/design-33-denounce-rollback-matrix.test.js "should keep participants() ORDER-STABLE and node-identical even though the rollback re-derived it")
```

```
S-033-03  LEAVE-THEN-REFILL fills the freed slot from existing candidates, identically on every node
Category: Announce/denounce determinism under rollback and the flag matrix
Phase: B  Goal: participation
Preconditions:
  - seed=33; A,B,C all press a discoverable joinGame; limit=2 (A,B win, C waits)
Actions:
  1. discoverParticipants(pred,2) -> ['A','B']; C not a participant.
  2. A.stopParticipating(); run 35 ticks so the leave finalizes.
  3. Re-run discoverParticipants(pred,2) on A and on B.
Expected:
  - Both nodes admit ['B','C'] — the freed slot is filled by re-derivation over existing candidates (§2.1), not by message arrival order; B.isParticipant('C')=true, B.isParticipant('A')=false.
Status: [T]    (tests/design-33-denounce-rollback-matrix.test.js "(a) §2.1 LEAVE-THEN-REFILL: a finalized leave frees a slot the NEXT discovery pass fills from existing candidates, identically on every node")
```

```
S-033-04  getStoppedParticipating <-> participants()-diff EQUIVALENCE under AUTO
Category: Announce/denounce determinism under rollback and the flag matrix
Phase: B  Goal: participation
Preconditions:
  - seed=33; A,B,C announced via discovery; A configured Flag2=AUTO
Actions:
  1. Snapshot avatar set `before` = A.participants().
  2. B.stopParticipating(); run 30 ticks.
  3. Path1: consume A.getStoppedParticipating(). Path2: diff `before` against A.participants() now.
Expected:
  - reported === ['B'] and diffed === ['B']; the two paths are equal (§2.2 equivalence).
Status: [T]    (tests/design-33-denounce-rollback-matrix.test.js "(b) §2.2 EQUIVALENCE: diffing the avatar set against participants() each tick yields the SAME departures as consuming getStoppedParticipating()")
```

```
S-033-05  the 2x2 flag matrix as COMBINATIONS, each its own documented behavior
Category: Announce/denounce determinism under rollback and the flag matrix
Phase: B  Goal: participation
Preconditions:
  - For each combo Flag1{ON,OFF} x Flag2{AUTO,CONSUME}: attendance on; A and P announce; P partitioned away past canonicalDisconnectTick
Actions:
  1. Read A.isParticipant('P') / A.isDisconnected('P') (and consume the report for ON+CONSUME).
Expected:
  - Flag1 OFF (either Flag2): P stays a participant (reservation), isDisconnected=true.
  - Flag1 ON + AUTO: P demoted at the disconnect tick (isParticipant=false).
  - Flag1 ON + CONSUME: P persists until getStoppedParticipating reports P, then consuming denounces (isParticipant=false).
Status: [T]    (tests/design-33-denounce-rollback-matrix.test.js "(c) §4 THE 2x2 FLAG MATRIX: each Flag1{ON,OFF} x Flag2{AUTO,CONSUME} combo produces its own documented membership/liveness")
```

```
S-033-06  consume-gated demotion RE-DERIVES across a rollback (queue-drain would diverge)
Category: Announce/denounce determinism under rollback and the flag matrix
Phase: B  Goal: participation
Preconditions:
  - seed=33; A,B announced (Flag2=CONSUME); A->B link latency=200ms so late A-inputs roll B back across the leave tick
Actions:
  1. B.stopParticipating() at a non-finalized tick; consume getStoppedParticipating() once -> contains 'B'.
  2. Run more ticks; a late A-input rolls B back ACROSS the consume.
Expected:
  - B.rollbackCount() > 0; B.isParticipant('B')=false after the rollback (demotion re-derived, NOT a drained-empty queue); B.isParticipant('B') === A.isParticipant('B') (15-test-2).
Status: [T]    (tests/design-33-denounce-rollback-matrix.test.js "(d) §15-2 CONSUME-GATED IDEMPOTENCE: rolling back across a getStoppedParticipating consume RE-DERIVES the demotion (queue-drain would diverge)")
```

```
S-033-07  adversarial reconnect-tick agreement under skewed observations
Category: Announce/denounce determinism under rollback and the flag matrix
Phase: B  Goal: participation
Preconditions:
  - seed=33; A,B,P attendance; P->A latency=0, P->B latency=150ms (skewed resume observations); P partitioned away then healed
Actions:
  1. Read A.reactivationTick('P') and B.reactivationTick('P') after reconnect resume beats flow.
Expected:
  - ra not null and ra === rb (one agreed reactivation tick, injected at tick T like the disconnect tick).
  - queryDisconnected('P', ra)=false on both; queryDisconnected('P', ra-1)=true on both (per-node liveness does not leak into membership; 15-test-3).
Status: [T]    (tests/design-33-denounce-rollback-matrix.test.js "(e) §15-3 ADVERSARIAL RECONNECT TICK: skewed local resume observations still agree on ONE reactivation tick (per-node liveness must not leak into membership)")
```

### Category 34 — Read API fine-grain — players() and handle prod/rollback semantics (design-spec; DESIGN_PARTICIPATION.md section 8)
Round-2 gap-closing set for §8: covers the read-API sub-claims round-1 cat24 left untested — prod-mode handle == bare id (freely hashable, no loud-fail), players() re-derivation on rollback, and immediate mid-tick mutation under releaseParticipant(). **IMPLEMENTED — all 8 scenarios now `[P]`.** The dev/prod handle distinction is selected by the dedicated `devMode` flag (prod `players()` = bare ids, dev = `ParticipantHandle` wrappers that dev-throw when hashed); `releaseParticipant()` is an immediate same-tick denounce; `players()` returns a fresh snapshot copy each call. Peers RECONCILED to announce (discoverable joinGame + in-step discovery). Deterministic (seed 34).

```
S-034-01  participants() identical across nodes (derived announced-set)
Category: Read API fine-grain
Phase: B  Goal: participation
Preconditions:
  - 3 peers A,B,C, each scripted {v:1}, attendance on (interval 100ms, timeout 200ms), seed 34
Actions:
  1. Advance 20 ticks (50ms each)
  2. Read participants() on A, B, C
Expected:
  - A.participants() === ['A','B','C']
  - B and C return the same sorted roster (derived view, not a side register)
Status: [P]    (tests/design-34-read-api-finegrain.test.js "should return participants() identical across every node (derived announced-set, not a side register)")
```

```
S-034-02  participants() re-derives identically after a late-change rollback
Category: Read API fine-grain
Phase: B  Goal: participation
Preconditions:
  - A scripted to press {v:7} at tick 3; B scripted {v:1}; A->B link latency 90ms; seed 34
Actions:
  1. Advance 20 ticks
  2. Read rollbackCount() on B; participants() on A and B; reconstructedValueAt('A',10) on both
Expected:
  - B.rollbackCount() >= 1 (it folded in the late change)
  - A.participants() === ['A','B'] and B.participants() equals it (re-derived, not stale)
  - reconstructedValueAt('A',10) === {v:7} on both nodes
Status: [P]    (tests/design-34-read-api-finegrain.test.js "should re-derive participants() IDENTICALLY after a late-change rollback (no stale side register)")
```

```
S-034-03  prod-default participants() ids are plain, hashable, round-trippable
Category: Read API fine-grain
Phase: B  Goal: participation
Preconditions:
  - 2 peers A,B with debug:false (prod default), scripted {v:1}, attendance on, seed 34
Actions:
  1. Advance 10 ticks
  2. For each id in A.participants(): check typeof and JSON round-trip
Expected:
  - every id is a plain string (not an opaque wrapper)
  - JSON.parse(JSON.stringify(id)) === id (persisting is harmless — replay-stable key)
Status: [P]    (tests/design-34-read-api-finegrain.test.js "should treat the engine's prod-default (debug=false) participants() as plain hashable ids (foundation for prod-handle == id)")
```

```
S-034-04  PROD-MODE players() handle IS the bare id — freely hashable, no loud-fail
Category: Read API fine-grain
Phase: B  Goal: participation
Preconditions:
  - 2 peers A,B with debug:false (production), scripted {v:1}, attendance on, seed 34
Actions:
  1. Advance 8 ticks
  2. handle = A.players() entry whose String() is 'B'
  3. A.hashValue({avatar:handle}) and A.hashValue({avatar:'B'})
Expected:
  - handle === 'B' (a plain string, not a wrapper)
  - hashValue({avatar:handle}) does NOT throw (no dev loud-fail in prod)
  - hashValue({avatar:handle}) === hashValue({avatar:'B'}) (equivalent to persisting the id)
Status: [P]    (tests/design-34-read-api-finegrain.test.js "should make a PROD-MODE players() handle BE the bare id — freely hashable with NO loud-fail (§8 (a))")
```

```
S-034-05  DEV-MODE players() handle is a wrapper that loud-fails in hash (prod contrast)
Category: Read API fine-grain
Phase: B  Goal: participation
Preconditions:
  - 2 peers A,B with debug:true (dev), scripted {v:1}, attendance on, seed 34
Actions:
  1. Advance 8 ticks
  2. handle = A.players() entry whose String() is 'B'
  3. A.hashValue({avatar:handle})
Expected:
  - handle !== 'B' (an opaque wrapper, not the bare id)
  - hashValue({avatar:handle}) THROWS /handle/i (the dev safety prod deliberately drops)
Status: [P]    (tests/design-34-read-api-finegrain.test.js "should keep a DEV-MODE players() handle as a wrapper that loud-fails in hash (contrast to prod) (§8 (a))")
```

```
S-034-06  players() re-derives identically after a rollback (never a side register)
Category: Read API fine-grain
Phase: B  Goal: participation
Preconditions:
  - A presses {v:7} at tick 3; B scripted {v:1}; A->B latency 90ms; attendance on; seed 34
Actions:
  1. Advance 20 ticks
  2. Read B.rollbackCount(); A.players() and B.players() (as ids); participants() on both
Expected:
  - B.rollbackCount() >= 1
  - sorted A.players() ids === sorted B.players() ids (derived rolled-back set, identical)
  - each node's players() set matches its own participants() floor
Status: [P]    (tests/design-34-read-api-finegrain.test.js "should re-derive players() IDENTICALLY after a rollback — never a stale side register (§8 (b))")
```

```
S-034-07  releaseParticipant() mutates players() mid-tick (immediate denounce)
Category: Read API fine-grain
Phase: B  Goal: participation
Preconditions:
  - 3 peers A,B,C scripted {v:1}, attendance on, seed 34
Actions:
  1. Advance 12 ticks
  2. a = A.players() ids (contains 'C')
  3. A.releaseParticipant('C')  (no advance between calls)
  4. b = A.players() ids
Expected:
  - b does NOT contain 'C', within the SAME tick
  - b.length === a.length - 1 and b === a minus 'C'
Status: [P]    (tests/design-34-read-api-finegrain.test.js "should reflect a releaseParticipant() MID-TICK: a=players(); release(x); b=players() drops x immediately (§8 (c))")
```

```
S-034-08  a captured players() snapshot is a stable copy across a later release
Category: Read API fine-grain
Phase: B  Goal: participation
Preconditions:
  - 3 peers A,B,C scripted {v:1}, attendance on, seed 34
Actions:
  1. Advance 12 ticks
  2. a = A.players() ids (the captured snapshot)
  3. A.releaseParticipant('C')
  4. Re-read A.players() ids
Expected:
  - the prior snapshot `a` still contains 'C' (snapshot is a copy, not a live view)
  - a FRESH A.players() read does NOT contain 'C' (reflects the release)
Status: [P]    (tests/design-34-read-api-finegrain.test.js "should leave an already-captured players() snapshot UNCHANGED by a later mid-tick release (snapshot is a copy, §8 (c))")
```

### Category 35 — Liveness as rolled-back simulation state + the agreed reactivation tick (design-spec; DESIGN_PARTICIPATION.md §6.4)
The reconnect protocol: liveness ("connected") is rolled-back sim state (a per-player tick-stamped on/off event log), with the reconnect flip at the AGREED first-resumed-beat stamp `R` (injected via rollback like the disconnect tick), riding the beat channel (idle reconnect) with two sub-moments (beat→connected, input→active). The disconnect half is `[P]` today; the reconnect half is `[T]` (today's most-recent-beat scalar erases the gap; reactivationTick/firstLiveInputTick are absent).

```
S-035-01  Disconnect liveness is an agreed, monotone, rolled-back tick (no reconnect)
Category: Liveness as rolled-back state + reactivation tick
Phase: B  Goal: liveness
Preconditions:
  - A,B,P beat (interval 2t, timeout 4t); P partitions away at ~tick 11 (last beat 10)
Actions:
  1. Advance past D = 10 + 4 = 14
Expected:
  - canonicalDisconnectTick(P) = 14, identical on A and B
  - queryDisconnected(P,13)=false, (P,14)=true, (P,40)=true on both nodes (monotone, no resume)
Status: [P]    (tests/design-35-reactivation-protocol.test.js "should make queryDisconnected MONOTONE and node-agreed for a pure disconnect")
```
```
S-035-02  The gap is PRESERVED across a reconnect (event log, not a scalar)
Category: Liveness as rolled-back state + reactivation tick
Phase: B  Goal: liveness
Preconditions:
  - P drops (D=14), then heals at tick 25; first resumed beat stamp = R
Actions:
  1. Read queryDisconnected(P, t) across the window and reactivationTick(P)
Expected:
  - queryDisconnected(P,14)=true and (P,20)=true (still disconnected INSIDE [D,R) — the window survives the resume)
  - queryDisconnected(P,R-1)=true, (P,R)=false (comes back on at the reactivation tick, not before)
  - (today a grow-only-max scalar erases [D,R) — RED)
Status: [T]    (tests/design-35-reactivation-protocol.test.js "should PRESERVE the gap ... (event log, not a scalar)")
```
```
S-035-03  reactivationTick(P) is AGREED across nodes with skewed observation
Category: Liveness as rolled-back state + reactivation tick
Phase: B  Goal: liveness
Preconditions:
  - P->A latency 0, P->B latency 150ms (A and B hear the resume 3 ticks apart); P drops then heals
Actions:
  1. Read reactivationTick(P) and queryDisconnected(P,R/R-1) on A and B
Expected:
  - A.reactivationTick(P) === B.reactivationTick(P) (the beat STAMP R, not the local observation tick)
  - queryDisconnected(P,R)=false and (P,R-1)=true on BOTH nodes
Status: [T]    (tests/design-35-reactivation-protocol.test.js "should agree on reactivationTick(P) across nodes with SKEWED observation")
```
```
S-035-04  Identical queryDisconnected profile on every node (no observation leak)
Category: Liveness as rolled-back state + reactivation tick
Phase: B  Goal: liveness
Preconditions:
  - Same skewed-latency disconnect+reconnect of P
Actions:
  1. Build the full true/false profile of queryDisconnected(P, 0..47) on A and on B
Expected:
  - profile(A) deep-equals profile(B) (liveness is rolled-back state driven by agreed ticks)
  - the profile contains a genuine disconnected stretch (not all-false)
Status: [T]    (tests/design-35-reactivation-protocol.test.js "should produce an IDENTICAL queryDisconnected profile on every node")
```
```
S-035-05  Idle reconnect flips connected via BEATS for a silent player
Category: Liveness as rolled-back state + reactivation tick
Phase: B  Goal: liveness
Preconditions:
  - P holds a constant intent (no new input change) after it heals — only attendance resume
Actions:
  1. Advance past the heal; read queryDisconnected(P) and reactivationTick(P)
Expected:
  - queryDisconnected(P, end)=false (reconnected purely from resumed beats, no game input)
  - reactivationTick(P) is non-null
Status: [T]    (tests/design-35-reactivation-protocol.test.js "IDLE reconnect: should flip connected back via BEATS for a silent player")
```
```
S-035-06  Two agreed sub-moments: reactivationTick (beat) strictly before firstLiveInputTick (input)
Category: Liveness as rolled-back state + reactivation tick
Phase: B  Goal: liveness
Preconditions:
  - P reconnects silent, THEN presses a fresh input (v changes) well later
Actions:
  1. Read reactivationTick(P) and firstLiveInputTick(P)
Expected:
  - both are numbers; reactivationTick(P) < firstLiveInputTick(P) (render "reconnecting" then "active")
Status: [T]    (tests/design-35-reactivation-protocol.test.js "should expose the TWO agreed sub-moments")
```

### Category 36 — The tick guard: deterministic-mutation enforcement (design-spec; DESIGN_PARTICIPATION.md §5.1)
An `inTick` flag (raised for the duration of each step) gates the membership MUTATIONS (discoverParticipants/getStoppedParticipating/releaseParticipant/stopParticipating) so they only run inside the deterministic loop: dev THROWS / prod NO-OPs when called outside. READS are never gated. Optional dev sandbox: the same flag inverted traps Math.random/Date inside the loop. Reads + in-step mutation are `[P]` today; the guard is `[T]`.

```
S-036-01  Reads are callable OUTSIDE the game loop
Category: The tick guard
Phase: B  Goal: determinism-guard
Preconditions:
  - A,B run; A sends input; no discovery
Actions:
  1. After ticks, call isParticipant/players/participants/reconstructedIds OUTSIDE any step
Expected:
  - none throw; isParticipant('A')=false (not announced) but the READ works (reads are never gated)
Status: [P]    (tests/design-36-tick-guard.test.js "should allow READ queries ... OUTSIDE the game loop")
```
```
S-036-02  A mutation called INSIDE the step is allowed (guard must permit in-loop)
Category: The tick guard
Phase: B  Goal: determinism-guard
Preconditions:
  - A,B press discoverable joinGame; the game's step calls discoverParticipants at tick 4
Actions:
  1. Advance; read membership
Expected:
  - A.isParticipant('A')=true and ('B')=true (announced from inside the deterministic loop)
Status: [P]    (tests/design-36-tick-guard.test.js "should ALLOW a membership mutation called from INSIDE the step")
```
```
S-036-03  DEV: mutation OUTSIDE the step throws loudly
Category: The tick guard
Phase: B  Goal: determinism-guard
Preconditions:
  - debug:true engine; peers announced via discoverable joins
Actions:
  1. Call discoverParticipants OUTSIDE any step
Expected:
  - throws (message mentions tick/step/deterministic) — out-of-loop mutation is a desync hazard
Status: [T]    (tests/design-36-tick-guard.test.js "DEV: should THROW when discoverParticipants is called OUTSIDE the step")
```
```
S-036-04  DEV: getStoppedParticipating / releaseParticipant / stopParticipating outside the step throw
Category: The tick guard
Phase: B  Goal: determinism-guard
Preconditions:
  - debug:true engine
Actions:
  1. Call each mutating function OUTSIDE the step
Expected:
  - each throws loudly
Status: [T]    (tests/design-36-tick-guard.test.js "DEV: should THROW for getStoppedParticipating / releaseParticipant / stopParticipating outside the step")
```
```
S-036-05  PROD: mutation outside the step is a sync-safe NO-OP
Category: The tick guard
Phase: B  Goal: determinism-guard
Preconditions:
  - debug:false engine
Actions:
  1. Call discoverParticipants OUTSIDE the step
Expected:
  - returns null/[]; isParticipant unchanged; players() unchanged (skipped identically on every node => sync-safe)
Status: [T]    (tests/design-36-tick-guard.test.js "PROD: should be a sync-safe NO-OP ... for a mutation outside the step")
```
```
S-036-06  SANDBOX (dev, optional): Math.random() inside the step is trapped
Category: The tick guard
Phase: B  Goal: determinism-guard
Preconditions:
  - debug:true engine; the step calls Math.random() at tick 3
Actions:
  1. Advance; observe
Expected:
  - Math.random() THROWS inside the loop (use the injected rng / the tick instead); passes through outside the loop
Status: [T]    (tests/design-36-tick-guard.test.js "SANDBOX (dev, optional): should TRAP Math.random() called from INSIDE the step")
```

### Category 37 — Per-peer knowledge tracking: forward only the delta (design-spec; DESIGN_PARTICIPATION.md §10.2)
The connected-graph forwarding optimization: each node keeps ONE table keyed by (direct-neighbour, source-player) and sends a neighbour only the DELTA it lacks; provenance kills echoes; `known[X]` advances ONLY on evidence (provenance / advertised frontier), never on an optimistic send, so a lost change is always re-delivered (no permanent hole). Determinism-neutral. **IMPLEMENTED — opt-in `inputForwarding` (DESIGN_PARTICIPATION.md §10.2 "Implementation status"); all 6 scenarios now `[P]`** (the knowledge base + `knowledgeFrontier(peer)` API + provenance no-echo + per-link delta + bounded bandwidth + evidence-only no-hole-under-loss). Forwarding targets only reachable neighbours (`_heardFrom`); the per-(peer,source,tick) throttle also fixed §10.1 limit (a). Convergence floor on a complete graph preserved.

```
S-037-01  Convergence floor on a COMPLETE graph (must be preserved)
Category: Per-peer knowledge tracking
Phase: B  Goal: wire-protocol
Preconditions:
  - A,B,C complete graph; only C presses v=5
Actions:
  1. Advance 20 ticks
Expected:
  - every node reconstructs C@v=5 and getState sum=100 (the convergence the optimization must keep)
Status: [P]    (tests/design-37-peer-knowledge-forwarding.test.js "should reach every node on a COMPLETE graph")
```
```
S-037-02  Per-(neighbour, source) knowledge frontier exists and reflects provenance
Category: Per-peer knowledge tracking
Phase: B  Goal: wire-protocol
Preconditions:
  - A hears C's input directly
Actions:
  1. Read A.knowledgeFrontier('C')
Expected:
  - returns {input:{src->tick}, beat:{src->tick}}; input.C covers C's tick-0 change (provenance: it came from C)
Status: [P]    (tests/design-37-peer-knowledge-forwarding.test.js "should expose a per-(neighbour, source) knowledge frontier")
```
```
S-037-03  PROVENANCE: B forwards C's input to A but never echoes it back to C
Category: Per-peer knowledge tracking
Phase: B  Goal: wire-protocol
Preconditions:
  - Connected line A-B-C (no A-C link); C presses v=5
Actions:
  1. Advance; read A's reconstruction of C and B's sends to C
Expected:
  - A reconstructs C@v=5 (forwarded via B); B sent C NO message carrying C's own input (provenance)
Status: [P]    (tests/design-37-peer-knowledge-forwarding.test.js "PROVENANCE: B forwards C's input to A but NEVER echoes it back to C")
```
```
S-037-04  DELTA FORWARD on a connected line converges A via B's relay
Category: Per-peer knowledge tracking
Phase: B  Goal: wire-protocol
Preconditions:
  - A-B-C line (no A-C link); C presses v=5
Actions:
  1. Advance 25 ticks
Expected:
  - A.reconstructedValueAt('C',20) == {v:5}; A.getState == B.getState
Status: [P]    (tests/design-37-peer-knowledge-forwarding.test.js "DELTA FORWARD on a CONNECTED line A-B-C")
```
```
S-037-05  BANDWIDTH: connected-line forwarding converges WITHOUT a redundant flood
Category: Per-peer knowledge tracking
Phase: B  Goal: wire-protocol
Preconditions:
  - A-B-C line; all three press once
Actions:
  1. Advance; count input copies on the wire
Expected:
  - all converge (A<->C via B); total input copies bounded (delta, not a perpetual re-flood of known changes)
Status: [P]    (tests/design-37-peer-knowledge-forwarding.test.js "BANDWIDTH: on a connected line, forwarding converges A,B,C WITHOUT a redundant flood")
```
```
S-037-06  EVIDENCE-ONLY: a lost forwarded change is eventually re-delivered (no permanent hole)
Category: Per-peer knowledge tracking
Phase: B  Goal: wire-protocol
Preconditions:
  - A-B-C line; B->A link 50% lossy; C presses v=5
Actions:
  1. Advance 60 ticks
Expected:
  - A still converges to C@v=5 (B kept retrying via x N / RTO; it did NOT mark A known-on-send) — the evidence-only invariant
Status: [P]    (tests/design-37-peer-knowledge-forwarding.test.js "EVIDENCE-ONLY: a forwarded change LOST in transit is eventually re-delivered")
```

---

## Productivity check (Goal A4 success criterion)

Writing these surfaced design questions not previously in `KNOWN_ISSUES.md`. The substantive new ones have been promoted there (see "v2 Redesign — Open Design Questions" #8–#13), notably: the **canonical disconnect tick** (S-005-06), **inputDelay ↔ acceptance-window composition** (S-003-07), **speculative-disconnect rollback** (S-004-03), **batched vs per-message rollback** (S-002-04), **app-traffic-as-liveness** (S-005-04), and the **canonical default input** for a participant with no history (S-003-05).
