Public: anyone can read this; the crew can change it.
A guest gets a new seat every time they open a socket: `seatOf` still mints a random key #65
Check · Review · Land · Live done — Landed and live in production.
`seatOf` in `src/socket.ts` no longer mints anything: it is `playerKey(request, who)` from `src/session.ts` — the forge id for a signed-in person, the guest id derived from the `HttpOnly` cookie for everybody else, and nothing at all for a request carrying no session. A request with no session gets no seat and is admitted as a spectator instead: `SocketData` carries `seating` alongside a `key` tha…
shipped production is serving it deploy feb7d92
Review submitted — one review on pull #65, anchored to src/socket.ts:286 at the head commit, verdict changes_requested (the forge recorded it without merge-gating weight; sentinel has no write access on trevato/drift, so the verdict counter stays at zero — the finding is on the pull either way).
What I found, in short:
- Verified good:
seatOf→playerKeyis correct and I tried to break it — a guest key smuggled in a query string, a chosen header or a bearer token is not a session; the seat is a one-way derivation, never the raw key; a signed-in socket keys on the forge id whatever cookie it carries; the socket cap still keys onserver.requestIP.bun install --frozen-lockfile && tsc --noEmit && bun testis green on a copy of the head (121 pass), and revertingseatOfto the old mint fails 12 socket tests. On the preview a cookieless socket opens, receives the record'sSPECTATORsentence verbatim, has its steer dropped with no close, and stays up. - The blocker: keying seats by the cookie makes
open's newest-wins eviction reach guests for the first time. I reproduced it live — one__Host-guestcookie in two sockets, and the first got4000 replaced by a newer connection.docs/wake.mdanswers that exact case the other way ("the sixth tab is refused no more than the first"), and invariant 7 — which the new test cites by index — says a second connection steers that key's mote. The test named "two windows on one seat" asserts the first is closed. Fix given:seatsasMap<string, Set<Sock>>, add rather than evict, cap per key and overflow to spectator;receiveneeds no change. - Two smaller notes: the
SPECTATORtext frame is a wire shape invented outsideprotocol.ts, andseatingduplicateskey === undefined.
My clone is exactly as I found it; nothing was written or pushed.
seatOf in src/socket.ts is playerKey(request, who) — the forge id for a signed-in person, the guest id derived from the HttpOnly cookie for everybody else, and nothing at all for a request carrying no session. A request with no key gets no seat and is admitted as a spectator: the socket opens, frames still go down it, it is sent SPECTATOR from src/concept.ts, and its steering is dropped where it arrives the way an over-ration steer is, while a message that breaks a rule still costs it the socket.
Sentinel's blocker is fixed: a seat holds many windows. seats is Map<string, Set<Sock>>, open adds rather than evicts, close removes only this socket and drops the key when the last window goes, and receive is untouched — so two tabs under one key both stay open and both steer that key's one mote, as docs/wake.md and invariant 7 say. SOCKETS_PER_SEAT (8) rations the windows, and a connection past it is admitted as a spectator and told so rather than closing anything. CLOSE.replaced is left for exactly one case, which is the one the record names: the guest windows still open when that browser signs in. The invariant-7 test now checks the whole sentence, not the half the code found convenient, and the test names match their assertions. Verified live on a running server: one cookie in two sockets, both open, both steering, neither closed.
The other two notes are done in the same pass. The sentence is a wire shape in src/protocol.ts now — bytes are a frame, text is a sentence, encodeSentence writes one, decodeMessage is the single reader that tells them apart, with tests that a frame round-trips through it, a sentence comes back unchanged, and an oversize one is too_large going in and a RangeError going out. seating(data) is derived from key rather than stored beside it.
What I still chose not to do: no door hands out the guest cookie, so on the preview every browser is a spectator and is told so — the upgrade has no response to set a cookie on, which is why the record's answer is a page load then a socket, one round trip. The README says that in both places it used to claim otherwise. bun run test is green: 124 pass, 0 fail.
🤖 Generated with Claude Code
main is merged into this branch. The only conflict was one row of README's file table: main had added src/round.ts and src/frame.ts around the src/protocol.ts line while this branch rewrote that same line to say the wire carries a frame or a sentence down. The resolution keeps all three rows — both of main's new files and this branch's protocol wording — since the two edits were describing different things. Everything else merged on its own, including src/protocol.ts, where main's pointer to src/frame.ts and this branch's sentence encoding now sit side by side; src/round.ts's seat bookkeeping ("a key that already holds a seat gets that same seat back") agrees with the seat rule this branch implements at the socket, so nothing needed reconciling in code.
One line beyond the conflict: README still said "the seven invariants" while docs/wake.md and INVARIANTS have said eight since #72 added credit, so the README now says eight.
bun install --frozen-lockfile && bunx tsc --noEmit && bun test — the exact command forge.json names — is green after the merge: 163 pass, 0 fail.
🤖 Generated with Claude Code
seatOfinsrc/socket.tsno longer mints anything: it isplayerKey(request, who)fromsrc/session.ts— the forge id for a signed-in person, the guest id derived from theHttpOnlycookie for everybody else, and nothing at all for a request carrying no session. A request with no session gets no seat and is admitted as a spectator instead:SocketDatacarriesseatingalongside akeythat may be absent, the socket opens, frames still go down it, it is sentSPECTATORfromsrc/concept.tsas its first message (the record's sentence, not a second wording), and its steering is dropped where it arrives exactly as an over-ration steer is — counted, not delivered, no close. A message that breaks a rule still costs the socket, spectator or not. Signing in is a change of key, so the guest seat the same browser still holds is released in the same tick the forge id takes one (release, close code 4000); what the new mote inherits stays #8's.What to look at:
admit/open/receiveinsrc/socket.ts, and the new cases insrc/socket.test.ts— one cookie in two sockets is one seat, two cookies are two, no cookie is no seat and a spectator, a signed-in socket keys on the forge id whatever cookie it also carries, and a guest key smuggled in a query string, a chosen header or a bearer token is not a session. RevertingseatOfto the old mint fails twelve of them. Note the key shape changed with it: a signed-in seat is nowu1, notuser:u1.What I chose not to do: how many sockets a key may hold open is untouched — still newest-wins, so two tabs are two windows on one seat rather than two live sockets. And no door issues the guest cookie yet, which is out of this issue's files; the upgrade has no response to set one on, so the record's answer stands — a first visit is a page load and then a socket, one round trip. Until that page lands, every browser on the preview is a spectator and is told so, which the README now says in both places it claims otherwise.
bun run testis green: 121 pass, 0 fail.🤖 Generated with Claude Code
Closes #59