# Liveness as a Corroborated Stream (§13.3 liveness — refines the per-player liveness signal)

Co-designed 2026-06-09. Makes the per-player liveness signal a **first-class corroborated stream**, exactly
like inputs: a sparse RANGE history per player, an accept/grace window, a corroborated-vs-raw split on the wire,
a per-peer `known` send-gate, and canon resolution (compare/union) in the checkpoint. This replaces the
grow-only-max `lastAttendanceTick` scalar + dense `_beats` list, which (a) cannot carry a disconnect→reconnect
GAP through the lightweight checkpoint, and (b) had no corroboration, so a liveness-only divergence could not be
reconciled deterministically.

Liveness only feeds the **hashed** state when membership feeds it (Flag 1 `disconnectLeaves`, §4): a disconnect
becomes a LEAVE at the agreed B7 tick. That is the only path where a per-node liveness skew is a real (not
presentational) desync — so the corroboration machinery below carries cost ONLY for sim-impacting liveness.

---

## 1. Membership presence vs sim-impacting attendance

Two separate concerns, with two owners:

- **Direct-peer membership (NON-sim, presence).** "Which peers am I directly connected to right now?" This is the
  TRANSPORT's job: `getPeers()` / `onPeerJoined` / `onPeerLeft` (backed by `HeartbeatLiveness` or native
  connection state). The recovery roster reads it directly (`_recoveryRoster` unions `transport.getPeers()`), so
  an idle/passive peer that emits no game traffic is still discoverable and recoverable. The engine runs no
  keep-alive of its own.
  > **HISTORICAL.** An engine-level `peerHeartbeat` / `MSG_PEER_HEARTBEAT` ('em-peer-hb') pulse once filled this
  > role (pinging any direct peer gone silent, so the receiver recorded it in `_heardFrom`). It carried no sim
  > data and its handler was a no-op — its only effect was membership, which `transport.getPeers()` already
  > answers. So it was **REMOVED** and direct-peer reachability is now a transport contract (`getPeers` +
  > join/leave events). The old `_drivePeerHeartbeat` / `_lastSentTo` / transport send-wrap apparatus is gone.
- **`attendance` (potentially SIM-impacting, RANGES).** The sender's aggregated knowledge of **every** player's
  liveness, as sparse runs, split corroborated/raw. Rides the §13.5.2 `known` send-gate + the §13.3.2 checkpoint
  /canon path — NOT a gossip forward. This is the membership *determinant* (who is a participant, for
  disconnect-leave), distinct from raw transport presence. Cross-graph liveness rides these ranges (a relay
  forwards a player's corroborated runs on the checkpoint/`_knownLive` path); §6.1 pulse-gossip is RETIRED — one
  propagation mechanism for sim-impacting liveness, not two.

`attendance` ranges are the aggregate catch-up/canon state (fill a peer's gaps, score, resolve) — the same role
the checkpoint's canon input set plays relative to a single input message.

### 1.2 RELEVANCE GATE on the sim-impacting ranges (DROP ON SIGHT, sender-side)

A hard split, mirroring how OWN INPUTS are dropped on sight (§3/§12, `SimulationEngine.js` `iAmSpectator`):
- **Own `attendance` RANGES are armed ONLY for relevant ticks** — exactly the own-input gate:
  `isParticipant(self) || _hasLiveDiscoverable(self)` (a discoverable announcement still in the retained window).
  A pure spectator NEVER arms its own ranges, so its liveness never feeds membership canon. This is a
  **consistency** requirement, not just efficiency: own inputs are already dropped on sight in spectator periods,
  so own liveness must be too, or the canon is built from mismatched data. Spectator gaps in your own ranges are
  correct — your membership already ended there (stopParticipating / disconnect-leave).
- **Direct-peer presence is NOT gated** — it's the transport's `getPeers()`, available for any connected peer
  regardless of participation, so the roster always sees a connected neighbour. Presence ≠ a participation claim:
  a spectator is reachable (roster) but never arms a sim-impacting range above.

---

## 2. The sparse RANGE tracker (`DisconnectTracker` → runs)

Replace `_beats` (dense, one entry per beat) + `_lastBeat` (max) with **alive-runs** per player:

```
_runs:    Map<playerId, Array<[start, end]>>   // sorted, disjoint, inter-run gap > timeoutTicks
_runsVer: Map<playerId, int>                   // bumped on every edit (the version, see §5)
```

A run `[s, e]` asserts continuous liveness over `[s, e]` (every internal beat-gap ≤ timeout), so the player is
**connected over `[s, e + timeout)`** and disconnected after, until the next run.

**`noteBeat(p, tick)`** (after the §4 window clamp): find the run whose `[start - timeout, end + timeout]`
contains `tick`. If one, extend it (`start = min`, `end = max`); if the extension now reaches a neighbouring run
within `timeout`, **merge** the two into one (this is the join — `[(2,6),(10,14)]` + beat 8 with timeout ≥ 2 →
`[(2,14)]`). Else insert a singleton `[tick, tick]`. Bump `_runsVer[p]`.

**Queries (gap-aware, derived from runs):**
- `queryDisconnected(p, t)` = `t` is in NO run's `[start, end + timeout)`.
- `canonicalDisconnectTick(p)` = `lastRun.end + timeout`. `reactivationTick(p)` = a run `start` that follows a
  gap > timeout.
- `rangesIn(p, [lo, hi])` = each run ∩ `[lo, hi]` (the checkpoint clip — see §6).
- `earliestAffectedTick` (unchanged role): a merge/extend that revives `[oldEnd + timeout, …)` returns that tick
  so the call site restarts a disconnect-conditional rollback from there.
- `gcBefore(floor)`: drop runs fully below `floor`; clip the straddling run's `start` up to `floor`.

---

## 3. Score (canon, §13.6)

Per player, the canon score gains a liveness term:

```
score_p = ∏_p ( 1 + corroboratedInputs_p + corroboratedBeatBoundaries_p )
```

`corroboratedBeatBoundaries_p` = the count of corroborated run-boundary beats THIS holder has for `p` in the
window (each run contributes its start + end). Integer, monotone in knowledge — "most-complete liveness" wins
the same way "most-complete inputs" does. (Count of runs is too coarse; covered span is not integer-stable.)

---

## 4. Accept / grace on receipt (same discipline as inputs)

A late liveness range is CLAMPED to a window floor by its corroboration, then merged (§2):
- **raw** ranges (only the sender holds them): intersect with `[acceptFloor, ∞)`.
- **corr** ranges (proven ≥ 2 holders): intersect with `[graceFloor, ∞)`.
The dropped sub-range below the floor is simply not applied — and because the clamp is BEFORE the merge, a late
bridging beat below the floor can never join two runs.

Worked example (`graceFloor = 5`, `acceptFloor = 10`, receive `[3,7],[8,12]`):
- corroborated → accept `[5,7],[8,12]`
- raw → accept `[10,12]`

**Nodes send their own beat "double":** each player's ranges on the wire carry a `corr` set and a `raw` set
(§5 wire). Corroboration of PRESENCE: a `raw` range I now also hold raises the author's corroboration; my own
beat echoed back in a peer's set raises MY OWN corroboration (the witness). Disconnect/reconnect are DERIVED
from corroborated boundary presences (§R4) — a gap is never "corroborated" directly; a bridging beat counts only
if corroborated, else the gap (disconnect) stands.

**Grace is the finalization DEADLINE for a corroborated liveness rescue.** A peer's disconnect-leave finalizes
at `disconnectTick + grace`. A corroborated range that would un-disconnect it is grace-acceptable ONLY while
that tick is still above `graceFloor`; arriving after it finalizes, it is below the floor and rejected — the
leave stands (a B8 transfer concern). So "network beats the straggler" applies to membership: a peer cut from
*everyone* cannot corroborate its own aliveness, so its uncorroborated self-claim is void past acceptance and it
is treated as having LEFT (it must re-announce to rejoin); a peer cut from one neighbour but seen by another is
saved IFF that neighbour's corroboration relays within grace.

---

## 5. Per-peer `known` send-gate + the ACK (the frontier of held ranges)

Track, per `(peer X, player P)`, what X actually HOLDS — not a bare "up to date" bool:

```
_knownLive[X][P] = { have: Array<[s,e]>, ackedVer, lastSendTick, backoffStep }
```

**The ack carries the ACCEPTED ranges, not just a version.** Acceptance can be PARTIAL — a receiver clamps a
sent range to its window (§4), so it holds only a sub-range. So the ack is like the input frontier ack's
`haveTicks`: it reports the ranges actually accepted. Example: A sends `raw:[[10,20]]`; B's `acceptFloor=14`
accepts only `[14,20]` and acks `have:[[14,20]]` — so ONLY `[14,20]` graduates (see Worked Example 1b). A bare
version-echo would wrongly credit the whole `[10,20]`.

- **`_ownLiveCorr` is a range-SET** (the union of ranges any peer has acked/echoed holding of MY OWN beats), and
  the corr/raw split on send is `run ∩ _ownLiveCorr` (corr) vs `run \ _ownLiveCorr` (raw) — a range
  intersection/difference, not a single watermark. (In steady state it IS a contiguous suffix, because each
  fresh beat is acked while still in peers' acceptance windows — but a partially-accepted old send leaves holes.)
- **`known`** to X for P = `_knownLive[X][P].have` covers everything A would currently SEND X (A's deliverable
  runs ⊆ have) AND `ackedVer === _runsVer[P]`. The `ackedVer` is a freshness tag (a stale ack for a since-edited
  send doesn't over-credit); the `have` ranges are the substance. Any edit to `_runs[P]` bumps `_runsVer[P]` →
  re-send on per-item backoff (send-gate-clamped) until re-acked. Silent in steady state.
- **Graduation = the ack.** When X accepts a `raw` range it acks `have`, A unions it into `_ownLiveCorr`, the
  range graduates raw → corr (grace-acceptable to farther peers) — and a range that ages past EVERY peer's
  acceptance un-acked is **void** (a lone beat that reached no one), like a lone uncorroborated input.
- **Passive arming:** `_knownLive[X][P].have` is also raised from X's OWN attendance / checkpoint listing P's
  ranges (proof X holds them, whoever delivered) — the `_armKnownFromCheckpoint` analogue.

Direct-peer presence (NON-sim, roster only) is the transport's `getPeers()` — no engine-side per-peer pulse map.

### 5.1 Wire shape
```
send:  { attendance: { P: { corr: [[8,14]], raw: [[20,26]], ver: 7 }, ... } }   // P only where _knownLive[X][P] stale
ack:   { haveLiveness: { P: { have: [[14,20]], ver: 7 }, ... } }                // the ranges actually ACCEPTED
```
The ack rides the existing `MSG_INPUT_ACK` channel (it already means "accepted/held").

---

## 5.2 Worked examples

**(1) Own beat: raw → ack → corroborated → re-send** (timeout 3, acceptWin 8, graceWin 16):
```
t=20  A.runs[A]=[[14,20]], ver=7, ownCorr=∅.   A→B {attendance:{A:{corr:[],raw:[[14,20]],ver:7}}}
      B acceptFloor=12: accept [14,20].  B→A {haveLiveness:{A:{have:[[14,20]],ver:7}}}
      A: known[B][A].have=[[14,20]], ownCorr={[14,20]} → [14,20] CORROBORATED, silent to B.
t=24  A.runs[A]=[[14,24]], ver=8 (edit) → dirty.  A→B {A:{corr:[[14,20]], raw:[[20,24]], ver:8}}
      B accepts, merges [14,24]; acks have=[[14,24]] → ownCorr={[14,24]}.
```
**(1b) Partial acceptance** — A sends `raw:[[10,20]]`, B `acceptFloor=14`: B accepts & acks only `[14,20]`; ONLY
`[14,20]` graduates; `[10,14]` stays raw (and is void if no peer ever held it fresh).

**(2) Network beats the straggler.** A cut from EVERYONE: A's 20→48 beats are uncorroborated; B/C finalize "A
left at 23"; on heal A's `raw` re-send is rejected past acceptance → A LEFT, must re-announce. A cut from B only
but C sees A: C relays A's beats to B as `corr` within grace → B fills the gap, un-disconnects A → A stays a
member (lost only if C's corroboration arrives after the leave finalizes).

---

## 6. Checkpoint integration

The checkpoint, over its range `[lo, hi]`, carries each player's `rangesIn(p, [lo, hi])` split corr/raw + `ver`
(so beats for `[6,10],[15,20],[25,30]` and checkpoint range `[17,28]` carry `[17,20],[25,28]`). On receipt:
- canon resolves liveness by the SAME compare/union as inputs (over corroborated ranges): union = max/merge of
  corroborated runs; compare = the winning checkpoint's corr ranges survive. Membership reads the CANON runs.
- `_knownLive` is updated from received checkpoints (a peer's checkpoint listing P's ranges proves it holds them
  at that version).

---

## 7. Build order

1. **`DisconnectTracker` → runs** (self-contained; existing unit tests are the guard).
2. `attendance` ranges wire (corr/raw split, `_knownLive` gate); roster uses the transport's `getPeers()`.
3. accept/grace clamp + corr/raw double-send + presence-corroboration.
4. `_knownLive` (versioned) send-gate.
5. checkpoint carry (clipped ranges) + canon resolve + scoring.

Steps 2–5 layer on top of 1 without changing it.
