dev / docs / HOST-ELECTION.md
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
# Host election — the suite contract

Who referees a room, and how every implementation agrees on the answer.

This document sits beside `chat/docs/PROTOCOL.md` and covers the one layer that
spec never had to describe: the **live stack's authority election**. The rooms
stack (chat, board) needs none — see §9 — so election has always been a
per-game invention, and six games invented five different rules. None of them
is derivable from the wire. A second implementation has to hand-mirror each one
from source, and when it gets one wrong the room does not error: it
**partitions**, two peers each following a different host, each convinced it is
right, each dropping the other's authoritative frames.

That is not hypothetical. peer-kit (the bot fleet) is the suite's second
implementation, and it got **three of six games wrong in production**, undetected
by unit tests, review, or matching golden vectors — fixed in peer-kit 2.0.1–2.0.3
(`57f535a` lampion, `2e99550` tessera, `6ee69d1` odeon). The header of
`peer-kit/src/ardegazu/peer/games/room.cljs:9-17` warns, in so many words, that
mismatched comparators partition a mixed room. The warning was there and was
violated three times. **§2 documents a fourth divergence that is still live.**
The conclusion this document draws is that the rule must be a wire artifact
with one shared implementation, not a convention replicated by careful reading.

---

## 1. Current state — the audit

### 1.1 The five shapes

Read as: *given two competing self-claims, which wins?* All six games agree on
one thing only — the final tie-break is the lowest libp2p peer id.

| app | claim fields on `ro` | liveness term | other terms | tie-break | comparator source |
|---|---|---|---|---|---|
| neon-grid (game1) | `live: 1`, **omitted when idle** | `playing\|countdown\|roundover` | — | lowest id | `game1/client/src/game1/game/sim.cljs:145` / `:153` |
| tessera (game3) | `live: 1`, omitted when idle | `playing\|countdown\|roundover` | — | lowest id | `game3/client/src/game3/game/sim.cljs:184` / `:192` |
| lampion (game5) | `live: 1`, omitted when idle | `playing\|countdown\|roundover` | — | lowest id | `game5/client/src/game5/game/sim.cljs:313` / `:321` |
| valley-blocks (game2) | `playing: 0\|1`, **always present** | `playing\|roundover` (no countdown phase exists) | **`joined`** — *not a wire field*, inferred | lowest id | `game2/client/src/game2/game/party.cljs:348-397` |
| seance (game4) | none | **none, deliberately** | — | lowest id | `game4/client/src/game4/game/sim.cljs:135` / `:140` |
| odeon (game6) | `age: <seconds>`, always present | none | **seniority**, ±`AGE-SLACK-S`=10 s | lowest id inside the slack band | `game6/client/src/game6/game/sim.cljs:126` / `:138` |

Frame builders: `mk-ro` at `game1/…/frames.cljs:27` (conditional `live`),
`game2/…/frames.cljs:50` (unconditional `playing`), `game4/…/frames.cljs:32`
(no term at all), `game6/…/frames.cljs:37` (unconditional `age`).

`ro` handlers: `game1/…/game.cljs:318`, `game2/…/party.cljs:348`,
`game3/…/game.cljs:469`, `game4/…/game.cljs:462`, `game5/…/game.cljs:513`,
`game6/…/game.cljs:228`.

**valley-blocks is a three-term comparator, not a two-term one.** This is the
correction the informal summaries miss, and it is the source of the still-live
bug in §2. `game2/…/party.cljs:360-367` (incumbent) and `:386-389` (bystander):

```clojure
;; incumbent                              ;; bystander
(cond                                     (cond
  (not (identical? my-live their-live))     (not (identical? their-live my-host-live))
  my-live                                   their-live
  (not (identical? joined claimant-joined)) (not (identical? claimant-joined host-joined))
  joined                                    claimant-joined
  :else (< my-id from))                     :else (< from host-id))
```

`joined` means *this peer is seated in the roster it publishes* — an engaged
referee outranks a parked menu tab. It rides no field. The claimant's
joinedness is **read out of the claim's own `ps` array** (`:350-351`: does
`from` appear in the roster `from` just sent?); the incumbent's own comes from
local state `p.joined`; the followed host's comes from local roster membership.
Three different derivations of one term, none of them named on the wire.

### 1.2 The second half nobody specified: vacancy detection

Contested claims are the examined half. **Concluding that there is no host** is
the other half, and it diverges harder — the six games share no mechanism, no
timeout, and no agreement on what "gone" even means.

| app | host heartbeat | follower's vacancy test | hostless-room election | grace |
|---|---|---|---|---|
| neon-grid (game1) | **none** | mesh `peerGone` only | "alone in the relay roster ⇒ claim" (`game.cljs:99-110`) | — |
| tessera (game3) | none | watchdog: host off the relay roster on **3 consecutive 2 s ticks** (`game.cljs:60-71`) | alone-in-room only | — |
| lampion (game5) | none | same watchdog (`game.cljs:65-76`) | alone-in-room only | — |
| seance (game4) | none | `GONE-GRACE-MS` = 8000 ms pending-gone map (`game.cljs:60-71`) | `ensure-host` — 2500 ms timer, re-armed until the lowest id claims (`game.cljs:112-123`) | 8 s |
| valley-blocks (game2) | **yes — `ro` every 4 s** (`party.cljs:116-126`) | `check-vanished!`: absent from **both** relay roster and open channels on 2 consecutive 2 s ticks (`party.cljs:183-206`) | 4 ticks (~8 s) with members present, 1 tick when alone, and only a **joined** peer claims; plus an unjoined-host takeover after 8 ticks (~16 s), never mid-round (`party.cljs:136-181`) | — |
| odeon (game6) | none | mesh `peerGone` only | alone-in-room only | — |
| peer-kit (all games) | none | mesh `peerGone` only (`room.cljs:190-195`) | claims only an empty room it was designated to host; **no backstop at all, by design** (`host/driver.cljs:11-14`) | — |

Two structural observations.

**(a) Only valley-blocks made vacancy detection sound, and the heartbeat is
why.** Every other game infers vacancy from *transport* signals — `roomPeers`,
`openPeers`, `peerGone`. Those answer "is this peer reachable", which is not the
question. A peer can be perfectly connected and not hosting (deposed, conceded,
suspended, its tab parked on the menu), and briefly unreachable while still
hosting (a relay flap). valley-blocks is the only game where the host is
*required to speak*, which is the only thing that makes "I have heard no host"
a fact rather than a guess about libp2p discovery timing.

**(b) The `incumbent-gone` escape is unreachable in the case it exists for.**
Every bystander branch has one — `(not (.has (net-room-peers) host-id))` at
`game1/…/game.cljs:350`, `game3/…/game.cljs:497`, `game5/…/game.cljs:542`,
`game2/…/party.cljs:385`, and `room.cljs:121-122` on the bot side. It asks the
relay roster. **A deposed peer that has conceded and gone quiet is still a room
member**, so the escape never fires, and a follower can be stuck behind a host
that has already yielded — with no self-healing except the mesh eventually
giving up on a channel that is not actually broken. This failure mode was found
during the odeon fix and it is architectural: a membership question cannot
answer an authority question.

### 1.3 Which differences are essential, which semantic, which accidental

**Essential — a general protocol must keep these configurable:**

- *The liveness predicate.* valley-blocks has no countdown phase in its
  protocol at all (`game2/…/frames.cljs:14-15`: the 3-2-1 is purely local), so
  its live set is genuinely smaller. odeon has no rounds whatsoever. Which
  local phases count as "live" is app shape and cannot be centralised.
- *The age clock.* odeon's `age` is wall-clock incumbency, aged forward between
  rosters (`game6/…/game.cljs:44-51`); the bot mirrors it with the same
  observed-age/observed-at pair (`peer-kit/…/games/odeon.cljs:46-60`). Whether
  an app maintains one is its choice.

**Semantic — different values, and the protocol must be able to express all of
them:**

- *live-beats-idle* protects **an interruptible episode**: don't void a round
  in flight.
- *seniority* protects **continuity of a standing authority**. odeon needs this
  precisely because it has no episodes — a liveness flag would be pinned at 1
  forever and degenerate to lowest id, which is the exact failure it must
  prevent: a cold-booted tab whose solo backstop fired before discovery
  finished stealing an established band's clock and resetting the transport for
  everyone. Not the same value as live-beats-idle, and not reducible to it.
- *joined-beats-parked* protects **competence to referee**. valley-blocks is
  the only game that noticed, and it noticed the hard way: its own header
  (`party.cljs:138-141`) records that naive lowest-id election "hands the crown
  to any parked menu tab and deadlocks the lobby". Every game has parked tabs.
  This one generalises to all of them.

**Accidental — the protocol should eliminate these:**

- `live` vs `playing`. One field, two names, no reason
  (`game2/…/frames.cljs:11-16` freezes both as "look wrong and are not").
- Omitted-when-idle vs always-present `0|1`. Two encodings of one boolean.
- seance carrying **no** term. Its `sim.cljs:130-133` forbids adding one, and
  the stated reason is entirely compatibility — "peer-kit's SeanceHost
  concedeRule is the same plain comparison, and a one-sided change would split
  rooms shared with the bot fleet." That is a true statement about migration
  cost, not a design argument. Seance has parked tabs like everything else.
- `joined` being **inferred** from `ps` rather than declared.
- The comparator existing **twice** in every app, once for the incumbent and
  once for bystanders, with hand-maintained symmetry. odeon already fixed this
  shape: one `ro-action` (`game6/…/sim.cljs:138`) returns
  `apply|reassert|concede|adopt|ignore` and calls one `incumbent-wins?` with
  whichever pair is the incumbent. Every app should have exactly that.
- **All five vacancy-detection mechanisms.** Nothing about neon-grid makes
  `peerGone`-only correct and nothing about tessera makes 3×2 s correct. These
  are five independent guesses at the same problem.
- Bystanders **re-deriving their host's liveness from their own local phase.**
  `game1/…/game.cljs:351`, `game2/…/party.cljs:385`, `game3/…/game.cljs:497`,
  `game5/…/game.cljs:541` all carry the comment "local phase mirrors my host's
  round", and peer-kit's overrides mirror the mirror
  (`peer-kit/…/tessera.cljs:137-141`, `lampion.cljs:235-239`,
  `valley_blocks.cljs:350-352`). This is the single largest source of the bug
  class: it makes ranking depend on correctly re-implementing another app's
  phase machine. §5 kills it.

---

## 2. A fourth divergence, still live: peer-kit's valley-blocks

`peer-kit/src/ardegazu/peer/games/valley_blocks.cljs:343-355` implements
`live → lowest id`. It has no `joined` term. Its comment says "game2's
bystander rule". game2's bystander rule has three terms
(`party.cljs:386-389`). The hosting side has the same omission:
`peer-kit/…/games/host/valley_blocks_host.cljs:65-76`.

game2's own golden vectors enumerate the full cross-product — 16 incumbent and
16 bystander scenarios named by their coordinates
(`game2/client/test/vectors/referee.json`, `bystander-gone{0,1}-their{0,1}-mylive{0,1}-theirj{0,1}`).
Replaying peer-kit's comparator against them, in the fixture's own topology
(challenger `…BBB…` < incumbent `…CCC…`, and the incumbent seated in its own
roster, so `host-joined` = true):

| vector row | game2 (deployed) | peer-kit 2.0.3 | |
|---|---|---|---|
| `bystander-gone0-their0-mylive0-theirj0` | keeps incumbent | **adopts challenger** | ✗ partition |
| `bystander-gone0-their1-mylive1-theirj0` | keeps incumbent | **adopts challenger** | ✗ partition |
| the other 14 | — | — | agree |

**2 of 16 rows diverge**, both in the same direction: the bot hands the room to
an unjoined parked tab while every browser keeps following the incumbent. The
hosting side diverges symmetrically — a hosting bot (which *is* seated in its
own roster, `host/driver.cljs:296-302`) concedes to an unjoined claimant with a
lower id where a browser host would keep the crown. Both are lobby-phase
conditions, which is where bots spend most of their time.

The 14/16 agreement rate is the point. This is what "matching golden vectors"
bought: peer-kit's fixtures pin framing and simulation faithfully, and the
comparator was never driven from game2's referee table at all. peer-kit's tests
consume game3's and game5's compiled testlib
(`peer-kit/test/vectors/generate-tessera.mjs`, `generate-lampion.mjs`) — nobody
ever pointed one at game2. §10 makes that a gate.

---

## 3. Scope: is this "not only games"?

Election is for apps with an **authoritative simulator**. The full decision tree
is §9; the short form:

- **Mergeable log ⇒ no election.** chat and board have none, and must not gain
  one. The OrbitDB log is the authority, its address derives from the room
  secret, access control *is* encryption, and every peer converges without
  anyone refereeing. An election here would add a single point of failure where
  none exists.
- **Authoritative simulator ⇒ election.** A live round, a shared clock, a turn
  order, a scarce resource to allocate (seats, instruments). The six games, and
  equally a shared timer, an auction, a co-watch session, a turn-based board
  game — none of which is a game in the arcade sense. Generalising the rule is
  what makes those buildable without re-inventing this document.

---

## 4. The claim frame

Every `ro` gains one additive object. Nothing else on the frame moves.

```
"cl": {
  "v": 1,        integer   comparator version — a tripwire, see §4.3
  "l": 0 | 1,    liveness  — an episode is in progress under this claimant
  "j": 0 | 1,    engaged   — the claimant is seated in the roster it publishes
  "a": <int>     age       — whole seconds of continuous incumbency, ≥ 0
}
```

Five keys, well inside the eight-pair `js-obj` key-order threshold
(`dev/docs/CLJS.md` § *Deterministic committed dist*); build it with sequential
`unchecked-set` anyway, as game2's builders do, because the order is
wire-visible.

`cl` rides the roster frame because that is where the claim already lives: `ro`
is the only frame that names a host, it is the frame the incumbent is required
to publish (§6), and it is already the frame every bystander branch inspects.
No new frame type.

### 4.1 Who computes what

| term | computed by | from |
|---|---|---|
| `l` | the claimant, about itself | its own phase, its own predicate |
| `j` | the claimant, about itself | is my id in the `ps` I am sending? |
| `a` | the claimant, about itself | wall seconds since it became host |

**A ranking peer computes none of these about anyone else.** It reads the four
integers off the wire. That is the whole design, and §5 states why it matters.

### 4.2 Term variance is per-app, and pinning is how an app opts out

An app that does not want a term **holds it constant**. A constant term never
discriminates, so it is a no-op in a total order. That is the subsumption
mechanism, and it is checkable: each app declares, per term, `varies` or
`pinned:<value>`, and the conformance suite proves a pinned term never varies in
any recorded scenario (§10.4).

Declarations live in the app manifest — `dev/apps.tsv`, peer-kit's
`apps.cljs` `app-def`, and a federation descriptor — **not on the wire.** A peer
must know its own variance before it builds its first claim, and a wire value
would be unverifiable anyway: a claimant asserting "my `a` varies" proves
nothing.

### 4.3 `cl.v` is a tripwire, not a fallback

A peer that sees `cl.v` greater than its own MUST NOT rank the claim. It does
not adopt, does not contest, and surfaces "this room is running a newer
protocol — update". Guessing at an unknown comparator is exactly the silent
partition this document exists to remove; a visible "update required" is the
correct failure. `cl.v` cannot *fix* a mixed-comparator room — only the
protocol prefix can prevent one (§7) — but it makes the condition observable
instead of mysterious.

---

## 5. The comparator: `ELECT/1`

A total order over `(claim, id)` pairs. Fixed in the shared layer. Not
configurable, not per-app, not overridable.

```
rank(claimA, idA, claimB, idB, slack) → A wins | B wins

1.  l      descending          1 beats 0
2.  j      descending          1 beats 0
3.  a      descending, slacked  |aA − aB| > slack ⇒ older wins
                                |aA − aB| ≤ slack ⇒ tie, fall through
4.  id     ascending           lowest peer id wins
```

`slack` defaults to 10 (odeon's `AGE-SLACK-S`, `game6/…/sim.cljs:124`) and is a
per-app constant, not a wire field — it only ever compares two ages under one
implementation's rule, and both sides must use the same value, so it belongs in
the manifest beside the variance declaration.

Ties at step 4 are impossible for distinct peers (`from == host` means it is not
a contest). `rank` reads no clock: aging an *observed* `a` forward by elapsed
wall time is the caller's job, exactly as odeon already splits it
(`current-host-age` at `game6/…/game.cljs:44` / `peer-kit/…/odeon.cljs:53`).
A comparator with a clock inside it cannot be pinned by golden vectors.

### 5.1 One function, both roles

`ELECT/1` is called with `(incumbent, challenger)`. The incumbent is *me* when I
am host and *the peer I follow* when I am a bystander. That is the whole
difference, and odeon's `ro-action` (`game6/…/sim.cljs:138`) already has the
shape: one decision function returning
`apply | reassert | concede | adopt | ignore`. Adopt that shape everywhere. Two
hand-symmetric functions in four repos is a maintenance hazard for no benefit —
`claim-decision` and `bystander-claim-wins` are the same rule written twice, and
the four peer-kit bugs were all failures of that duplicated symmetry.

### 5.2 The invariant worth stating out loud

> **All app semantics live in claim construction. None live in claim ranking.**

A peer that builds its own claim wrongly under- or over-claims — the room may
follow the wrong host, but *every* peer follows the **same** wrong host. That is
a policy bug: visible, debuggable, and self-limiting.

A peer that *ranks* claims differently partitions the room: two hosts, two
worlds, silence.

`ELECT/1` moves every app-shaped decision into construction and leaves ranking
as four integer comparisons over wire values. **Every partition bug becomes a
policy bug.** That is the design's actual payload, and it is why §1.3's last
bullet — bystanders re-deriving their host's liveness from local phase — has to
go. Under `ELECT/1`, a bystander remembers **the last `cl` its host published**
and ranks that. It never models another app's phase machine. Tessera and
lampion broke on exactly that modelling; the failure becomes unrepresentable.

### 5.3 Does `ELECT/1` subsume the five shapes? Honestly.

| app | `l` | `j` | `a` | `ELECT/1` reduces to | vs today |
|---|---|---|---|---|---|
| valley-blocks (game2) | varies | varies | pinned 0 | `l ≻ j ≻ id` | **identical** |
| odeon (game6) | pinned 1 | pinned 1 | varies | `a`(slack 10) `≻ id` | **identical** |
| seance (game4) | pinned 0 | pinned 0 | pinned 0 | `id` | **identical** |
| neon-grid (game1) | varies | pinned 1 | pinned 0 | `l ≻ id` | **identical** |
| tessera (game3) | varies | pinned 1 | pinned 0 | `l ≻ id` | **identical** |
| lampion (game5) | varies | pinned 1 | pinned 0 | `l ≻ id` | **identical** |

All six reduce exactly, **at the pinnings shown**. Three caveats, stated
plainly:

1. **The exactness depends on pinning, and pinning is a choice with a cost.**
   The trio (game1/3/5) reduces exactly *only because `j` is pinned 1*. Turning
   `j` on is what these games actually want — it is the fix for the parked-tab
   problem valley-blocks documented — but it is a **behaviour change**, not a
   re-expression: today an `l` tie falls straight through to lowest id, and
   with `j` varying it does not. §7 treats that as a separate, later,
   fork-requiring step, and §8 sequences it so no game changes behaviour and
   representation in the same release.
2. **A fixed term order does not subsume odeon if `j` varies there.** Under
   `l ≻ j ≻ a`, a joined newcomer would outrank an unjoined conductor with five
   minutes of incumbency — wrong for odeon, where clock continuity is the whole
   value. Odeon therefore pins `j: 1`. This is not a fudge: odeon's roster
   question is answered by the roster, and gating the hall's clock on it was
   never wanted. But it is the honest boundary of the design — **`ELECT/1`
   subsumes all six only because each app can make a term inert, not because a
   single term order is right for everyone.**
3. **`a` is not a Byzantine-safe term.** See §11.

Given (1)–(3), the alternative the brief floats — a named rule id on the wire so
a second implementation can *refuse* rather than guess — is strictly worse here:
under `ELECT/1` a rule id would be a constant, and per-app *term variance* is
what actually needs declaring, which must be known before the first claim is
built and cannot be verified from a wire assertion. What survives of that idea
is `cl.v` (§4.3): refuse on an unknown **comparator version**, which is the case
where guessing is genuinely unsafe.

---

## 6. Vacancy detection: the incumbent must speak

Contested claims are settled by §5. Vacancy gets a state machine, and the
premise is a wire requirement:

> **A host MUST republish its `ro` at least every `HEARTBEAT_MS`.**
> `HEARTBEAT_MS = 4000`, valley-blocks' proven-in-production number
> (`party.cljs:116-126`).

Absence of publication then *is* vacancy — an observation, not an inference.

**Follower state machine** (per follower, about the host it follows):

| transition | trigger |
|---|---|
| `FOLLOWING → VACANT` | no accepted `ro` from the host for `VACANT_MS` = 3 × `HEARTBEAT_MS` = 12000 ms |
| `FOLLOWING → VACANT` | mesh `peerGone` for the host (fast path, unchanged) |
| `FOLLOWING → VACANT` | host absent from **both** relay roster and open channels on 2 consecutive 2000 ms ticks (game2's `check-vanished!`, fast path) |
| `VACANT → CLAIMING` | immediately if the relay roster is empty (solo/offline boot) |
| `VACANT → CLAIMING` | otherwise after `HOSTLESS_WAIT_MS` = 8000 ms, **and** only if my claim wins `ELECT/1` against every claim I have seen in that window, **and** I am the lowest id among peers with `j = 1` |
| `CLAIMING → FOLLOWING` | any accepted `ro` that outranks my claim |

Three deliberate choices:

- **`VACANT_MS` is the primary detector and `roomPeers` is demoted to a fast
  path.** A conceded host stops publishing, so the timeout fires in 12 s
  regardless of room membership. That is the architectural fix for §1.2(b) — the
  `incumbent-gone` escape stops being load-bearing. Keep the escape as an
  optimisation; never as the only self-heal.
- **Only `j = 1` peers may claim.** valley-blocks' rule
  (`party.cljs:158-162`), generalised. A parked menu tab must never referee.
- **The unjoined-host takeover survives** (`party.cljs:166-181`): a host that
  stays `j = 0` while engaged players wait is taken over by the lowest engaged
  id after 16 s, **never mid-round**. Under `ELECT/1` this is no longer a
  bespoke timer — it is `j` discriminating, on a delay. Keep the delay: the term
  makes the *outcome* right, the delay keeps a boot race from thrashing.

**Compatibility gate on the timeout — this one bites if you miss it.** A `/2/`
host that predates this document publishes `ro` only on roster change, so a new
follower's 12 s timeout would declare a silent-but-alive old host vacant and
start contesting, every 12 s, forever. Therefore:

> A follower MUST apply `VACANT_MS` **only** to a host whose accepted `ro`
> carried a `cl` object. Against a host publishing legacy fields only, fall back
> to today's transport-based detection for that app.

`cl` present is proof of heartbeat. This is the one rule that makes §8's
additive stage safe.

---

## 7. Compatibility and migration

### 7.1 The rule that governs everything here

The suite's standing compat discipline is *additive fields never bump a schema
version* (`catalog.json` stays `v: 1`; see the compat notes in
`chat/docs/PROTOCOL.md` and the suite's additive-field practice). **Comparators
are the exception, and the reason is structural:** an additive data field is
safe because a reader that ignores it still computes a *usable* answer. A
comparator is agreement-critical — if two peers rank the same inputs
differently, there is no usable answer, only a partition. A comparator change
must therefore be **simultaneous across every participant**, which additivity
cannot deliver.

And it cannot be papered over with detection. `ro` carries no version, so
"old peer" is inferable only from the absence of `cl`; a three-peer room with
two old and one new would need the new peer to run two comparators at once and
produce one answer. There is no such answer.

So the migration splits along that seam:

- **The representation change is additive.** Adding `cl` and switching to
  `ELECT/1` at the pinnings in §5.3 changes **no** outcome for **any** of the
  six games. Provably: the reduction table, enumerated exhaustively (§10.5).
- **A rule change is not.** Any game that wants a term it does not have today —
  the trio turning on `j`, seance turning on `j`, anyone changing a liveness
  predicate — takes a **protocol-prefix bump**.

### 7.2 The prefix bump, when it is needed

The prefix already carries the version: `/<app>/2/<roomId[0:16]>`
(`gameN/client/src/gameN/net/peers.cljs:29`). `/3/` is the sanctioned breaking
change. Note the deployed prefixes are **not** uniformly the codename —
valley-blocks is `/game2/2/`, the sole exception (`peer-kit/…/apps.cljs:24`);
a `/3/` cut must carry the same stem the deployed build speaks, or interop dies
as a silent negotiation failure.

Mixed-room behaviour under a `/3/` cut, spelled out: a `/2/` peer and a `/3/`
peer **never negotiate a stream**, so they never exchange a frame, never see
each other's hellos, and land in **disjoint rooms**. Each sees a room with one
member and hosts its own island. That is the same cutover semantics
`chat/docs/PROTOCOL.md` describes for v1→v2 ("no version negotiation … never see
each other"), and it is the right failure: a disjoint room says *nobody else is
here*; a partitioned room says nothing at all while quietly corrupting.

**The staleness cost is smaller than it looks, and this matters to the
recommendation.** The games' service worker ships `skipWaiting: true` +
`clientsClaim: true` (`gameN/client/scripts/build.mjs:70-80`) — an updated build
takes over on the next page load, with no banner and no user action. That is why
game1–6 are `versioned=n` in `apps.tsv` and why they need no version.json
machinery before a fork. The residual exposure is an installed PWA that has not
loaded online since the cut: it plays alone until it reloads. Acceptable, and
the same property the v1→v2 cutover already relied on.

### 7.3 Per-app plan

| app | stage A (additive, no fork) | stage B (`/3/`, only if wanted) | mixed-room behaviour in stage A |
|---|---|---|---|
| valley-blocks (game2) | `cl` alongside `playing`; `l`,`j` varying, `a` pinned 0. **Identical rule.** Also **fixes §2** | none needed — already exactly `ELECT/1` | old peer sends `playing` only ⇒ new peer synthesises `l` from `playing`, `j` from `ps` membership, `a`=0 ⇒ identical ranking. No divergence on any of the 32 vector rows |
| odeon (game6) | `cl` alongside `age`; `a` varying, `l`,`j` pinned 1. **Identical rule** | none needed | synthesise `a` from `age`, `l`=`j`=1 ⇒ seniority+slack+id, unchanged |
| seance (game4) | `cl` with all three pinned. **Identical rule** (plain lowest id) | flip `j` to varying — the game most exposed, having no term at all today | old peer sends nothing ⇒ synthesise all-pinned ⇒ lowest id, unchanged. Removes the `sim.cljs:130-133` freeze without changing behaviour |
| neon-grid (game1) | `cl` alongside `live`; `l` varying, `j` pinned 1, `a` pinned 0. **Identical rule** | flip `j` to varying | synthesise `l` from `live` presence ⇒ `l ≻ id`, unchanged |
| tessera (game3) | as game1 | as game1 | as game1 |
| lampion (game5) | as game1 | as game1 | as game1 |
| peer-kit (bots) | consumes the same kit; synthesis mode; **no wire change and no browser release required** | must ship each game's `/3/` within that game's release window | a bot on the wrong prefix does not see the room — visible, not silent |

**Recommendation: do stage A for all six, and treat stage B as optional per
game.** Stage A is behaviour-preserving, needs no fork, and lands the §2 fix on
day one. Stage B buys `j` for four games and costs a hard fork each; take it
per game, on its own merits, once stage A has been live long enough to trust.

If a single answer is wanted for the brief's "recommend one": **additive, with a
prefix bump held in reserve per game.** The reason additive works at all is that
the rule is not changing — and where the rule genuinely changes, additive is not
merely inadvisable, it is unsound.

---

## 8. Where the single definition lives

The requirement is one definition consumed by **both** implementations, not
mirrored by them. Today the browser rule lives per-app in `game/sim.cljs` and
`game/party.cljs`, and the bot rule lives per-game in
`peer-kit/src/ardegazu/peer/games/*.cljs` — twelve sites, hand-kept in sync,
four known failures.

**Options weighed:**

- *Vendored `net/elect.cljs` in the game1 canon.* Fits the live-stack vendoring
  line (rule 6). But **peer-kit cannot consume it**: peer-kit's vendoring is
  retired by policy (`dev/docs/CLJS.md` § *deps.edn rules*, and the disarmed-gate
  history behind it), so the bot would re-copy the file — reinstating the exact
  bug class. **Rejected on the requirement.**
- *Promote all of `net/` to a kit.* This is the standing deferred decision
  (`dev/docs/CLJS.md` § *Deferred follow-ups*), and it needs a human: it buys one
  fix site and costs the byte-identity gate, the zero-indirection debugging the
  games rely on, and another deterministic dist. Coupling the election contract
  to that decision blocks this work on an unrelated call. **Rejected on scope.**
- **A tiny dedicated kit — `elect-kit`. Recommended.**

```
elect-kit/
  src/ardegazu/elect/core.cljs     rank, the vacancy state machine, nothing else
  test/vectors/elect.json          the comparator's golden truth table
```

npm `ardegazu-elect-kit`, ns `ardegazu.elect.*`, one row in `dev/apps.tsv`
(`elect-kit  elect-kit  -  kit  elect-kit  n`). **Source-only**, on the
consumer's classpath via a relative `deps.edn` path into `node_modules`, exactly
as social-kit is consumed (`dev/docs/CLJS.md` § *deps.edn rules*) — both
consumers are CLJS, so there is no dist to keep deterministic and the objection
that sinks the `net/`-promotion option does not apply here. Consumed as a
sha-pinned npm git dep by every game and by peer-kit; bumped with
`ardz kit-release elect-kit` (house rule 2 — the committish-only bump trap is
the CLI's problem, not yours).

**The kit's boundary is the whole point:**

| in the kit | in the app |
|---|---|
| `rank` — `ELECT/1`, pure, no clock | the liveness predicate (which phases are live) |
| the vacancy state machine as pure transitions | building `cl` (§4.1) |
| the constants `HEARTBEAT_MS`, `VACANT_MS`, `HOSTLESS_WAIT_MS`, default slack | the frame builders and `ro` wiring |
| the `apply/reassert/concede/adopt/ignore` decision (odeon's `ro-action` shape) | acting on the decision (to-lobby, resync, re-hello) |

Small enough to read in one sitting, which is a design goal: the artifact a
federated implementation is pointed at should be small enough to port
correctly.

---

## 9. When an app needs no host at all

Three questions, in order.

**1. Is the shared state a mergeable log?** Commutative operations, convergent
after a deterministic tie-break. → **No election.** Derive the log address from
the room secret, let access control *be* encryption, and let the mailbox cover
offline peers. chat and board are this, and adding an election would be a
regression: it introduces a single point of failure where the design has none.
An app in this class should never carry `ro`, `cl`, or a host id.

**2. Does the state need an authoritative simulator?** A live round; a shared
clock; a turn order; a scarce resource to allocate (seats, instruments); a
verdict no peer can compute independently. → **Election required.** `ELECT/1`,
§4–§6. Not only games: a shared timer, an auction, a co-watch session, a
turn-based board.

**3. Both?** Put durable facts in a log and elect only for the live layer, and
make the elected host's output **derivable** — a new host must be able to
reconstruct from the log. The suite already has the exemplar: leaderboards are
match receipts co-signed by ≥ 2 identity-verified players, dropped into public
weekly mailbox rooms and gossip-merged. **No host is trusted with scores at
all.** When data looks authoritative but does not need a simulator, that is the
pattern to copy, not election.

Corollary worth stating for anyone building in class 2: **election is a liveness
mechanism, not a durability one.** Every game today voids the in-flight round on
a host change (`to-lobby` in every `host-lost` path), and that is correct
precisely because rounds are not durable. If your state must survive a host
change, it belongs in a log — question 3, not question 2.

---

## 10. Proving conformance

This is the section that earns the document. What caught the three fixed bugs —
and what did **not** catch §2's — is a specific testing shape, and it must be a
requirement rather than a practice.

**10.1 Truth comes from the reference implementation's own compiled code.**
Never hand-write expectations. A generator imports the reference's compiled test
entry (`gameN/client/test-dist/testlib.js`, written by `npm test` in that repo)
and builds the fixture from its real frame builders and real decision
functions — see `peer-kit/test/vectors/generate-lampion.mjs:1-25`, whose header
states exactly what it imports and what (one bot-policy detail with no
counterpart) it transcribes. Transcription is allowed only where the reference
has nothing to import, and must be named in the header.

The reference is reached through an **env var** (`GAME5_TESTLIB`,
`generate-lampion.mjs:31-35`) so no cross-repo filesystem path ever enters a
tracked tree — house rule 9, and the publish gate's `idpat` patterns include
path fragments, so a leaked path aborts the publish.

**10.2 Drive through real frames; assert through public surface.** The
implementation under test is fed `{t:"ro", …}` and friends through its public
message path, and inspected through public getters. Never poke `_phase`,
`hostId`, or any internal. `peer-kit/test/lampion.test.mjs:62-97` is the shape:
a `DRIVE` map of the frame sequences that put a client into each phase, plus a
`seated()` helper that establishes a followed host the same way the wire would.
Internal-poking tests pass while the wire path is broken — that is precisely how
a mirrored phase predicate goes unverified.

**10.3 Full cross-product, not samples.** For `ELECT/1`:
`l × j × a-band × id-order × (bystander | incumbent) × (host-present | host-vacant)`.
game2's `referee.json` is the model — 32 scenarios named by their coordinates
(`bystander-gone0-their1-mylive1-theirj0`), each a real op/wire/state trace, so a
failure names its own row.

**10.4 Prove the pinnings.** A pinned term is load-bearing for §5.3's
subsumption claim. The suite must assert that across every recorded scenario for
an app, each `pinned:<v>` term takes only that value — otherwise "identical
rule" is an unchecked assertion.

**10.5 Two opposite discrimination proofs, both reported as ratios.**

- *For the stage-A equivalence claim:* replay the legacy comparator and
  `ELECT/1`-over-synthesised-terms across the full cross-product and assert
  **zero** rows differ. This is the compatibility proof, and it is mechanical.
- *For the fix claim:* revert the implementation to the previous rule, rerun,
  and count flipped rows, asserting a floor —
  `peer-kit/test/lampion.test.mjs:114-132` does this
  (`assert.ok(differs.length >= 6, …)`), and it is the only thing that
  distinguishes "my override works" from "my override restates the inherited
  default". **Rows where two rules agree by construction are not a gap:** report
  the ratio (§2's 14/16) so the number is read as coverage, not as failure.

**10.6 Both directions, and the gate that would have caught all four bugs.**
Today only browser-as-reference → bot-under-test exists, and only for the games
somebody thought to check: peer-kit's suite consumes game3's and game5's
testlib and never game2's, which is exactly why §2 is still live. Therefore:

> **Every `gameN/client/test/vectors/referee.json` must have at least one
> consumer outside its own repo, and every `elect-kit` vector must be replayed
> by every consumer.** A referee table with no external consumer is an untested
> interop contract, and CI fails on one.

**10.7 The kit gets vectors of its own.** `elect-kit/test/vectors/elect.json`
pins `rank` exhaustively (including the slack-band boundaries at exactly
`±slack` and `±slack ± 1`, where odeon's `>` rather than `>=` is load-bearing)
and the vacancy state machine as a transition table. Every consumer replays them
against its own build. This is the artifact a federated implementation certifies
against.

**10.8 A note on running browser interop.** It serialises suite-wide: the
relay's origin allowlist permits only ports 4173/5173 and a `shadow-cljs server`
claims both. Plan mixed-room verification as a single-occupancy step, and expect
the mailbox mint to reject non-browser origins.

---

## 11. Where this touches identity — for the federation reader

A wire-explicit election rule is a prerequisite for federation: a third-party
implementation cannot be expected to guess per-app conventions, and §1 is the
evidence that even a *first*-party implementation cannot. Four notes for whoever
writes that spec.

**11.1 Claims are bound to the ephemeral peer id, not the suite identity.** The
authenticated `from` is the libp2p peer id — noise-authenticated, and **fresh
per page load** (`chat/docs/PROTOCOL.md` §1: "the relay cannot link a person
across page loads"). So the tie-break is over identifiers with no durable
meaning. Two consequences: the lowest-id tie-break is **grindable** (regenerate
the key until the id sorts low), and no claim is attributable to a person.

**11.2 `ELECT/1` is a coordination protocol, not Byzantine consensus.** Any room
member can seize hostship by lying — claim `l: 1`, `j: 1`, `a: 99999`. The
defence is that room membership is *already* the trust boundary: the room secret
is the capability, admission is proven by the sealed hello, and a peer inside
the room can disrupt the room in a dozen cheaper ways. Say this plainly in the
federation spec so nobody builds a trust assumption on `a`. What the terms do
buy is that `id` is no longer the *only* discriminator, and a lying `a` is at
least detectable by observation across a heartbeat window.

**11.3 Election must never gate identity exchange.** Today `hi` goes to every
open peer and `peerIdentity` resolves independently of who hosts
(`peer-kit/…/room.cljs:186-188`). Preserve that: a peer must be able to verify
suite identities without knowing who referees, or identity verification acquires
a dependency on a liveness protocol. **The kit must not touch identity.**

**11.4 The available hardening, deliberately not in `ELECT/1`.** A claim tuple
could be signed by the suite Ed25519 identity — `identityOf(from)` already
exists in the net layer, and social-kit's signed-then-wrapped envelopes are the
machinery. That would make `a` and `j` attributable and let a room policy prefer
identity-verified hosts. It is deferred here because it costs a signature per
heartbeat on every claim, it makes hostship require a suite identity (today it
does not), and it lands on the identity layer's known federation blocker — the
id bridge's hard-coded `ardegazu.ro` origin binding. Note it as future work in
the federation spec, not as part of `ELECT/1`.

---

## 12. Implementation plan

Six phases. Each is independently shippable, independently verifiable, and
independently reversible.

**Phase 0 — no version-banner work needed.** Recorded as a phase because the
obvious plan is to add one first, and it is unnecessary: the games' SW is
`skipWaiting: true` + `clientsClaim: true` (§7.2), so builds self-adopt on the
next page load and `versioned=n` is correct. Do not touch the game SW configs
for this migration. *Rollback: n/a.*

**Phase 1 — `elect-kit`.** New repo, source-only, no consumers.
`ardegazu.elect.core` with `rank`, the vacancy transitions, the constants, the
`apply/reassert/concede/adopt/ignore` decision. `test/vectors/elect.json` per
§10.7. Plus, in the same phase, the six **reduction proofs** of §5.3: a test per
app that replays that app's referee vectors through `rank`-over-synthesised-terms
and asserts zero divergence. If a reduction does not come out zero, §5.3 is
wrong for that app and this plan stops until it is corrected.
*Rollback: nothing consumes it; delete or leave dormant.*

**Phase 2 — peer-kit consumes the kit (bug fix, no wire change).** Replace all
six `claimBeats` overrides and all three `concedeRule`s with the kit's `rank`
over synthesised terms. Delete the per-game comparators. This alone **closes
§2's live divergence** — no browser release, no wire change, no prefix bump,
because `ELECT/1`-over-synthesis reproduces game2's three-term rule exactly.
Gate on §10.6: peer-kit's suite must now consume **all six** games' referee
vectors, game2's included. *Rollback: unpin the kit, restore the overrides —
they are pure functions with vector coverage, so the revert is mechanical.*

The migration's first user-visible act being a bug fix rather than a risk is
deliberate; sequence it that way.

**Phase 3 — browsers consume the kit, in synthesis mode.** Per app: replace
`claim-decision` / `bystander-claim-wins` (and game2's inline `cond`s) with the
kit's decision function, adopt odeon's one-function shape, and **stop deriving
the incumbent's liveness from local phase** — remember the last `cl` (or, in
synthesis mode, the last synthesised tuple) the followed host published. No
frame change yet. Ship **game1 first** (the CLJS canon, rule 6 — the vendored
layers replicate from it), verify a mixed browser+bot room, then the rest.
*Rollback per app: `ardz release` the previous build; nothing on the wire moved,
so a rolled-back app is indistinguishable from an un-migrated one.*

**Phase 4 — `ro` gains `cl`; the heartbeat becomes mandatory.** Per app,
additive: emit `cl`, prefer `cl` when present, fall back to synthesis, and start
publishing `ro` every `HEARTBEAT_MS`. Apply `VACANT_MS` **only** against hosts
whose claims carry `cl` (§6's compatibility gate — this is the phase where
skipping it produces a 12-second contest loop against every un-migrated host).
Verify a three-way room: migrated browser + un-migrated browser + bot.
*Rollback per app: re-release without `cl`; readers fall back to synthesis, and
the heartbeat's disappearance is exactly the legacy case the gate already
handles.*

**Phase 5 — `/3/` cuts, per game, only where a term is wanted.** Recommended
order: **game4** (gains `j`; most exposed, since it has no term today), then
**game1, game3, game5** (gain `j`). **Do not fork game2 or game6** — they are
already exactly `ELECT/1`. Each cut is one line in
`<name>/client/src/<name>/net/peers.cljs:29` plus the matching `app-def` in
`peer-kit/…/apps.cljs`, released in the same window (a bot on the wrong prefix
sees an empty room — visible, not silent). Carry the deployed stem, not the
codename: valley-blocks would be `/game2/3/` if it were ever cut.
*Rollback: revert the prefix line and re-release. The rollback is clean
**because** the fork is hard — a `/3/` room is disjoint, so reverting re-unites
everyone on `/2/` with no partitioned state to reconcile.*

**Phase 6 — retire the old machinery.** Delete the per-app `claim-decision` /
`bystander-claim-wins` pairs, the four bespoke vacancy mechanisms (game3/game5's
`host-misses` watchdog, game4's `GONE-GRACE-MS` map and `ensure-host`, game1's
and game6's nothing-at-all), and demote every `incumbent-gone` / `roomPeers`
escape to an optimisation behind the heartbeat timeout. Update rule 6's vendored
canon list and `dev/docs/CLJS.md` if the kit changes what game1 vendors.
*Rollback: this phase only removes now-dead paths; revert the commit.*

**Not in scope, deliberately:** promoting `net/` to a kit (the standing deferred
decision, §8); signing claims with the suite identity (§11.4); any change to
chat, board, or the rooms stack, which need none of this (§9).

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/dev.git