oficina / meet Public

docs: spec revision 9, and the slice 2 record

W-2. The whole record of what this slice decided lived in
`.superpowers/`, which is gitignored: progress.md, every task report,
every ruling and every carried item. None of it merges, and the spec was
still revision 8 while the code disagreed with it in ten recorded
places — one of them, the §2.5 room-page row, a real narrowing of a
published authorization contract.

- The spec gains §15, "Revision 9 — what slice 2 changed, and why":
  the hub session key as a keyed hash of the session cookie (§4.2); the
  attaching stream GET's Accept requirement and its 406 (§4.5); the
  `?probe=1` resume probe (§4.5); the 410 scoped to the tombstone's own
  room and session (§4.5); `/signal` refusing a malformed room id and a
  session with no live participation before the limiter (§4.4, §2.5);
  the room-page row narrowed to a participation whose kind is not member
  (§2.5, ruling 14); a resume refused the moment its window passes
  rather than when the tick notices (§4.5); `leave` not idempotent
  (§4.3); the stale-gen 409 carrying no gen (§4.6); and the four
  client-only terminal reasons — signedout, unreachable, nowebrtc, and
  gone as "You cannot join this meeting." Each with the reason it was
  decided on, and the one that narrows a published contract marked as
  narrowing it. The revision line at the top is bumped.

- `docs/superpowers/reviews/2026-09-10-slice-2-record.md` carries the
  rest: the ledger's rulings and decisions, the twelve whole-branch
  findings with their dispositions, the carried items with the slice
  each belongs to, the deploy note (every deploy drops every live call,
  because the hub is in-process — spec'd, not solved; deploy outside
  meeting hours), and how to run the three gates.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
Paul Campbell pushed by paul@keymail.dev 354d1a7dff614cc7c4d76194f555fabbd505d869 parent 98fdbaf
2 files changed, +504 −7
  • docs/superpowers/reviews/2026-09-10-slice-2-record.md +293 −0
  • docs/superpowers/specs/2026-09-07-meet-design.md +211 −7
diff --git a/docs/superpowers/reviews/2026-09-10-slice-2-record.md b/docs/superpowers/reviews/2026-09-10-slice-2-record.md
new file mode 100644
index 0000000..9013ca0
--- /dev/null
+++ b/docs/superpowers/reviews/2026-09-10-slice-2-record.md
@@ -0,0 +1,293 @@
+# Meet slice 2 — the record
+
+Branch `slice/2-room`, `77227de..` through the whole-branch fix wave.
+Spec: `docs/superpowers/specs/2026-09-07-meet-design.md`, revision 9
+(§15 is this slice's departures from revision 8).
+
+Slice 2 built the room: the hub, `POST /signal`, the SSE stream, the
+holding screen, the browser client, and a Playwright gate that makes a
+real call between two real Chromium contexts.
+
+This file exists because everything below was, until now, written down
+only in `.superpowers/`, which is gitignored. The reasoning did not
+merge and did not survive the worktree. What follows is that ledger's
+substance: the decisions, why each one was taken, what the reviews
+found, and what is still open with the slice it belongs to.
+
+---
+
+## 1. Rulings and decisions
+
+Ten of these changed what the spec says and are written into §15 of the
+spec itself, with their reasoning. They are listed here by name only, so
+this file stays the index and the spec stays the contract:
+
+§15.1 the hub session key · §15.2 the `Accept` gate and its 406 ·
+§15.3 the `?probe=1` resume probe · §15.4 the scoped 410 ·
+§15.5 `/signal`'s refusals before the limiter · §15.6 the narrowed
+room-page row · §15.7 the resume window enforced without waiting for the
+tick · §15.8 `leave` is not idempotent · §15.9 the stale-gen 409 carries
+no gen · §15.10 the four client-only terminal reasons.
+
+The rest are implementation decisions the spec has no opinion about.
+
+**The hub**
+
+- A hub method that dereferences `h.rooms[sqid]` must nil-check the
+ room and return `ErrUnknownPeer`. Cost if wrong: a nil-map panic on a
+ bad sqid.
+- A second resume of an already-attached peer replaces the attachment,
+ and the old stream is told `lost {reason: "replaced"}`. No tombstone is
+ written, so the participation itself continues. Two tabs of one
+ browser are the one-peer-per-session rule, working as intended.
+- `Kind` is written in exactly one place (`setKindLocked`) and
+ `Admitted` in exactly one (`admitLocked`), because a kind change owes
+ every admitted peer a fresh roster and a second writer would be a
+ second place to forget it.
+- `StandingFor` is never called under the hub lock. Every caller drops
+ the lock, reads, retakes it, and **re-finds** the participation,
+ because it may have ended while the read was in flight.
+- An admitted guest is never ended by a grant expiring (ruling 14): the
+ participation is the authority once someone is in. `standingLostLocked`
+ is the one place that decides what a loss of standing costs, because
+ what it costs depends on the kind held.
+- `Admit` settles the actor's authority completely — found, standing
+ applied, admitted, member — before it looks at the target at all.
+ The other order let any joined participation learn from the shape of
+ the refusal whether a candidate peer id existed in the room.
+- Headstone rooms are never reaped and `h.rooms` is never deleted from.
+ Any future reaper must wait out `TombstoneLife`; the note sits beside
+ `End`.
+- `gap` is the highest evicted `seq` per sender above `last[from]` — a
+ deviation from the Task 3 brief, which asked for a simpler test.
+
+**The signal endpoint**
+
+- The rate limiter is keyed on session + room, never on anything the
+ request body claims, and the key is built before the body is read.
+ Keyed on the claimed peer id, one session could drain another's bucket
+ (peer ids are published in rosters) or evade its own by rotating ids.
+- `peer` is 32 lowercase hex or 400, and `type` is logged only from a
+ fixed list. Attacker-chosen text never becomes a map key or a log
+ argument.
+- Hub errors other than the four sentinels are logged and answered 500.
+ `ErrNotYours` and `ErrUnknownPeer` share one byte-identical 404 body
+ including the boot id.
+
+**The pages**
+
+- The removal confirm page checks fresh member standing *and* an
+ admitted member participation (`hub.PeerNameFor`), and names the
+ person, not the meeting.
+- After a removal the confirm tab goes to `/m/{sqid}/join` — inert,
+ opens no stream — not back to the room. Both confirm pages' Cancel and
+ back links go there too.
+- The takeover confirm page 303s to `/join` when this session holds
+ nothing to take over, and re-checks standing before `hub.Takeover`.
+- Toggle buttons keep a fixed accessible name (`aria-label` Microphone /
+ Camera) with `aria-pressed`, plus a child span carrying the state text
+ — "On"/"Muted", "On"/"Off" — marked `aria-hidden`. Never colour alone,
+ and never a label that changes under the person's cursor.
+- The three device pickers use `ui/field-select`. AGENTS.md's `[x]`
+ Fields rule is hard: `devices.js` fills the vendored component rather
+ than hand-rolling an equivalent.
+- Load-time `getUserMedia` on the holding screen stands, with a
+ `visibilityState` guard and a `visibilitychange` retry: it is a page
+ the person navigated to on purpose.
+
+**The client**
+
+- Every terminal state carries a way out: a link to `/m/{sqid}/join`.
+- A stream that dies moments after its `hello` has proved nothing: the
+ backoff is cleared only by a stream that stayed open five seconds.
+- The stall ceiling is gated on time as well as count — eight stalls
+ *and* sixty seconds since the last `hello` — so a room that is merely
+ slow is not declared unreachable.
+- `terminal()` clears `admitted` and keeps `peer`: the peer id names
+ what this tab held, which is worth having in a diagnosis, and nothing
+ reconnects with it once `stopped` is set.
+
+**The gates**
+
+- The browser gate reports a client failure as `t.Skip` carrying the
+ exact text, never by deleting or loosening a scenario.
+- The buffer-overrun scenario pins `SO_SNDBUF` through a custom
+ listener: without it the gate depended on the machine's `tcp_wmem`.
+- The renewal scenario is production-shaped — a six-second TTL through
+ the development-only `X-Meet-Test-TTL` header, with a floor of four —
+ rather than lowering the client's `HEALTHY_STREAM`.
+
+---
+
+## 2. Whole-branch review findings, and what happened to them
+
+Whole-branch review at `cfc0dc0`: 53 files, 13,338 insertions, all three
+gates green, 48 mutations applied to a throwaway copy — 40 killed, 8
+survived, every survivor a test gap rather than a bug. Verdict: merge
+with W-1 and W-2 fixed first.
+
+| # | Finding | Disposition |
+|---|---|---|
+| W-1 | The five guest-isolation audience filters (§4.3, ruling 9) are gated by nothing: each could be mutated away with the whole suite green, including two §13 names as slice-2 gates and one the browser `guest` scenario was written to catch | **Fixed in this wave.** Five hub tests, three meettest tests over HTTP, and a `guest` scenario with a second guest left knocking. Every mutation the reviewer lists is now killed. |
+| W-2 | The branch's whole record lives in a gitignored directory, and the spec was never revised past 8 | **Fixed in this wave.** Spec revision 9 (§15) carries the ten departures with their reasons; this file carries the rest. |
+| W-3 | Nothing proves the boot id changes across a restart | **Fixed in this wave.** The browser `restart` scenario compares `state().boot` before and after. |
+| W-4 | Dropping the subject from the hub session key is caught by nothing | **Fixed in this wave.** A test signs the same browser out and in again and asserts the old key no longer answers. |
+| W-5 | Two guards with no test: `Remove`'s self-target check, `hubStanding`'s ended-meeting early return | **Fixed in this wave.** The first has a test; the second is belt-and-braces behind callers that check `EndedAt` first, and its comment now says so. |
+| W-6 | `-race` cannot be run the way the repo is configured, on the first slice with real concurrency | **Fixed in this wave**, with a caveat: `make race` exists and AGENTS.md names it as the one exception to the cgo-free rule. The CI step is present but guarded — see §4. |
+| W-7 | `lost{buffer}` is sent for three endings that are not buffer overruns | **Carried — next fix wave.** See §3. |
+| W-8 | `TERMINAL.badlast` is unreachable | **Fixed in this wave.** The dead arm is gone; the 400 an attaching stream returns is invisible to `EventSource` and is handled as a stall, which is what actually happens. |
+| W-9 | Three wordings for one move | **Carried — the copy pass.** Already in the tracked copy catalogue. |
+| W-10 | The test hook ships to production | **Recorded, not fixed.** See §3. |
+| W-11 | Three hub maps only ever grow | **Recorded, not fixed.** See §3. |
+| W-12 | The tick has no per-pass deadline and does serial database reads | **Fixed in this wave.** Each pass runs under a deadline of `tickEvery`, with a test that a read which never returns cannot stall the pass. |
+
+Mutation survivors the reviewer reported, and where each is killed now:
+
+| Predicate | Killed by |
+|---|---|
+| `rosterFor`'s member-only waiting queue | hub, meettest, browser |
+| `rosterFor`'s admitted-only mesh | hub, meettest |
+| `admitLocked`'s admitted-only `peer` fan-out | hub, meettest, browser |
+| `refreshRostersLocked`'s admitted-only refresh | hub |
+| `knock`'s admitted-members audience | hub, meettest, browser |
+
+---
+
+## 3. Carried items, and where they go
+
+**Next fix wave**
+
+- **W-7, `lost{buffer}` for three endings that are not buffer
+ overruns** (`admission.go`'s `Leave`, `hub.go`'s unjoined timeout and
+ lapsed resume window). `buffer` is the one reason the wire contract
+ defines as *not* terminal — "followed by a resume". Today this is
+ harmless because `room.js` sets `stopped` and closes the stream before
+ the event can be read, but the safety lives entirely in the client, and
+ §8's drive surface will have other callers. A peer whose join keeps
+ failing can loop: unjoined timeout → `lost{buffer}` → resume → 404 same
+ boot → fresh stream → join → thirty seconds → repeat, and the stall
+ counter never fires because each round gets a `hello`. **Not fixed here
+ because either fix changes the wire contract** — a new terminal reason,
+ or an ending that sends no event at all — and this wave took no
+ wire-contract changes. Fix it with the client change in the same
+ commit, and re-run the browser gate.
+- **W-10, the test hook in the release binary.** `window.meet.state()`
+ is defined unconditionally and `TestHandle` compiles into the release
+ build (unreachable without `NewForTest`, which `main` never calls).
+ Neither exposes anything secret, and the CSP keeps third-party script
+ off the page. Confining either one costs more than it buys today:
+ build-tagging `TestHandle` means `make ci` grows a `-tags` argument,
+ and gating `window.meet` on the environment means the browser gate
+ tests a page production does not serve.
+- **W-11, three hub maps that only grow.** `h.rooms` is never deleted
+ from (declared; `End`'s comment explains why a reaper is dangerous).
+ `h.bySession[sqid]` keeps an empty inner map per room forever, and
+ `h.byPerson` a zero entry per person forever. All three are bounded by
+ real meetings and real people — an outsider cannot mint a room id that
+ reaches `Open`, because standing is checked first — so this is a slow
+ leak on a long-lived process, not an attack surface. It is also
+ invisible while every deploy empties the hub (§4). Fix it with, or
+ after, whatever ends that.
+- **A just-added member gets 404 from the removal confirm page until the
+ next tick.** Recorded in spec §15.11.
+- **Watch: the limiter now sits behind a gate that takes the hub lock.**
+ Revisit if hub-lock hold time grows. Recorded in spec §15.5.
+
+**The copy pass**
+
+- W-9's three wordings for one move: "Use this tab instead" (holding
+ notice and `room.js`'s `wayOut`), "Use this tab" (takeover confirm
+ submit), and "This meeting is already open in another tab" versus
+ "This meeting is open in another tab."
+- The holding notice's "already open in another tab" is untrue during
+ the resume window; "You are already in this meeting from this browser."
+ is the candidate.
+- "Remove `<name>` from the call?" reads oddly for a guest who is not in
+ the call yet.
+
+All three are in `docs/superpowers/copy/2026-09-10-slice-2-strings.md`.
+
+**Slice 3 — relay and `/ice`**
+
+- `createPC` passes `{ iceServers: [] }` (`room.js`), with the floor
+ notice keyed on `cfg.RelayConfigured`. Those are the two places to
+ change.
+- `GET /m/{sqid}/ice` is §2.5's "participation, admitted" row — the same
+ authority as a non-`join` signal. `hub.Find` plus `p.Admitted` is the
+ whole check; do not consult standing (ruling 14).
+- Renewal (§5.3) needs a timer that survives a resume. The browser gate
+ already has a `renewal` scenario for the *stream* TTL using the
+ development-only `X-Meet-Test-TTL` header; copy that shape for the
+ credential rather than inventing a second one.
+- ICE restart already has a client path: `restartIce()` with
+ `requestReset` as the fallback, and the `replay` scenario asserts
+ `have-local-offer` to prove it fires.
+- W-12 is now bounded, but `/ice` adds a per-request credential mint on
+ a path that already does a database read. Watch the tick's deadline.
+
+**Slice 4 — chat and screen share**
+
+- `chat` and `share` are already named in `messages.go` and refused with
+ 400 in `signal.go`. Removing that arm and adding the fan-out is the
+ whole server change; `broadcastLocked(r, m, admittedPeers)` is the
+ audience.
+- §4.4's 2 KB chat cap has no code yet. It must be checked against the
+ decoded text, not the body, and it must not read the text for anything
+ else — the captured-log test extends to *accepted* chat in slice 4,
+ which is the first time an accepted message has content worth
+ protecting.
+- W-1 is fixed, so chat's "arrives at the admitted context and not at a
+ waiting one" is now two lines: extend
+ `internal/meet/hub/isolation_test.go` and
+ `internal/meettest/isolation_test.go`, which exist for exactly this.
+- Screen share adds a second video track per peer. `tile()` keys on peer
+ id and `renderTiles` rebuilds from `pcs`, so the tile model needs a
+ track dimension **before** the share arrives, not after.
+
+---
+
+## 4. Deploying this slice
+
+**Every deploy drops every live call.** The hub is per process and dies
+with it. This is spec'd (§4.2, §4.5) and not solved: slice 2 is the
+first release where it has a user-visible cost.
+
+What a deploy does to people who are in a meeting:
+
+- every browser's resume is refused with a 404 carrying the new boot id;
+- each one closes every peer connection, then joins afresh;
+- **members** re-enter at once and negotiate again — a few seconds of
+ black tiles;
+- **guests go back to waiting** and must be admitted again by a member.
+
+So: **deploy outside meeting hours** until slice 3, or until a drain
+exists. There is no partial mitigation worth the words — the participants
+are in memory, and the memory goes.
+
+Nothing else about a deploy is unusual. Migrations apply at boot, once
+each; `GET /healthz` and `GET /api/version` are answered by the framework
+outside app middleware.
+
+---
+
+## 5. The gates, and how to run them
+
+| Gate | Command | Notes |
+|---|---|---|
+| The one gate | `CGO_ENABLED=0 make ci` | vet, gofmt, tests, migration check. Cgo-free, as AGENTS.md requires. |
+| Race | `CGO_ENABLED=1 go test -race ./internal/...`, or `make race` | **Needs `CGO_ENABLED=1`**: `-race` requires cgo, and the repo is cgo-free everywhere else. This builds a test binary, never a release, which is why it is the one exception. |
+| Browser | `make browser` | ~130 s, 18 scenarios. Needs node, a network fetch of Playwright and Chromium, and **UDP** — inside the Claude Code bash sandbox Chromium gathers zero ICE candidates and every call fails with frames stuck at 0, so **run this target with the sandbox off**. |
+
+`make browser` is deliberately not part of `make ci`: the one gate the
+team runs on every push must not depend on a toolchain the app itself
+does not use. Run it before every review that touches a page or the
+client. Nothing enforces that, which is a known weakness and the reason
+this row is here rather than in a comment.
+
+**The race step in CI.** `.amadan/ci.d/50-race` runs `make race` when
+the runner has a C compiler and prints a skip line when it does not. The
+guard is there because the cloud runner's provisioning
+(`amadan/docs/ci-cloud-runner.md`) installs the Go toolchain and Node and
+never names a C compiler, and a step that fails on every push would be
+worse than no step. **When somebody confirms `cc` on the runner, delete
+the guard** — a race gate that can silently skip is half a gate.
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md
index 7c9558b..1b7b966 100644
--- a/docs/superpowers/specs/2026-09-07-meet-design.md
+++ b/docs/superpowers/specs/2026-09-07-meet-design.md
@@ -1,13 +1,16 @@
# 🤖 Meet — design
Design, 2026-09-07. Direction approved by Paul in conversation; this is the
-written form. **Revision 8**, approved by Paul on 2026-09-08 with one
-direction added as ruling 15; revision 7 carried the two corrections of
-the sixth Codex review (`CODEX-FINDINGS-MEET-R6.md`, verdict: accept
-with corrections); §14 has all six rounds. Drafted by the Meet
-session (Fable 5.1) from a brainstorm with Paul on 2026-09-06 and
-2026-09-07. Every ruling in §1 attributed to Paul was given in that
-conversation or on the discussion it cites.
+written form. **Revision 9**, 2026-09-10: slice 2 built the room, and
+§15 records the ten places where what it built departs from revision 8,
+each with the reason it was decided on. Revision 8 was approved by Paul
+on 2026-09-08 with one direction added as ruling 15; revision 7 carried
+the two corrections of the sixth Codex review
+(`CODEX-FINDINGS-MEET-R6.md`, verdict: accept with corrections); §14 has
+all six rounds. Drafted by the Meet session (Fable 5.1) from a
+brainstorm with Paul on 2026-09-06 and 2026-09-07. Every ruling in §1
+attributed to Paul was given in that conversation or on the discussion
+it cites.
Meet is video calling for the Oficina suite: a member makes a meeting,
now or for later, sends one link, and the people who follow it set up
@@ -1235,3 +1238,204 @@ became ruling 15 and §8.4 in revision 8. Implementation begins at slice
1 from this revision.
+
+---
+
+## 15. Revision 9 — what slice 2 changed, and why
+
+Revision 8 was written before a line of the room was built. Slice 2 (the
+room, the hub, the signalling and the browser client) departed from it
+in ten places. Each one was decided in the slice's own ledger with a
+reason; this section is that reasoning in the published spec, so the
+code and the spec stop disagreeing. Nothing here reopens a §1 ruling.
+Where a departure narrows something revision 8 published — the §2.5
+room-page row is the one that does — it is marked.
+
+Every item carries the section it amends. Read the section and then this
+entry; the entry wins.
+
+### 15.1 §4.2 — the hub session key is a keyed hash of the session cookie
+
+Revision 8 said the hub binds a participation to "the session" without
+saying what names one. The implementation needed a value that is stable
+for one browser's sign-in, different for two browsers of one person, and
+not itself a credential.
+
+**Decision.** The key is `hex(sha256(per-boot salt | subject | session
+cookie value))`. Three properties earn the three parts: the per-boot
+salt means the key is meaningless across a restart (the hub is empty
+then anyway); the subject means the key cannot outlive the person it
+belongs to; the cookie value is the only per-browser thing rastrillo
+exposes. The key is neither the session token nor the token hash
+rastrillo stores, so a hub key leaking would not be a credential
+leaking.
+
+**Why not the alternative.** The ledger's first ruling was the subject
+plus the session's `At` instant, and it was wrong: rastrillo stores
+`created_at` as RFC3339, second granularity, so two sign-ins in one
+second would have collided.
+
+**Cost if wrong.** Two tabs of one browser share a key, which is exactly
+the takeover case §4.2 already specifies. Two browsers of one person do
+not, which §4.2 permits (four streams per person).
+
+### 15.2 §4.5 — the attaching stream GET requires `Accept: text/event-stream`
+
+**Decision.** `GET /m/{sqid}/stream` in its attaching forms answers
+`406 {"error": "accept"}` unless the request's `Accept` names
+`text/event-stream`. `EventSource` always sends it; a cross-site
+top-level navigation never does.
+
+**Why.** Without the gate, a link on another site could make a signed-in
+browser occupy its own session's one participation slot for the full
+stream TTL — no data read, but the person's own next real attempt gets a
+409 and a takeover page for no reason they can see.
+
+**Why 406 and not the usual 404.** This check runs before any lookup or
+authorization, so its answer is identical for every room id and every
+person and reveals nothing. §2.5's "404, never 403" rule exists to stop
+a refusal being an oracle; there is nothing to be an oracle about here,
+and a mis-headered client is better told the truth than sent round the
+fresh-stream ladder. Ruling 15 and 16 in the slice ledger.
+
+### 15.3 §4.5 — the resume probe, `?probe=1`
+
+**Decision.** Both stream forms accept `?probe=1`. A probe runs the
+whole refusal ladder of §4.5 and attaches nothing: it answers `204` (with
+`no-store`) when the resume would have been accepted, and otherwise the
+same 404, 409 or 410 with the same body an attaching request would have
+received. Probes are exempt from the `Accept` gate of §15.2 — `fetch`
+does not send that header, and a probe occupies nothing.
+
+**Why.** `EventSource` gives the browser no access to a non-200 status:
+a refused attaching stream is indistinguishable from a network failure.
+Without a probe the client cannot tell "this participation was removed"
+from "the wifi went", and §4.5's whole ladder — read the reason, act on
+it — is unreachable from a browser. The client probes, reads the status,
+then attaches.
+
+**Cost.** One extra GET per resume.
+
+### 15.4 §4.5 — the 410 is scoped to the tombstone's own room and session
+
+**Decision.** An ended participation's tombstone is answered only through
+the room and the session it was bound to. A resume naming that peer id
+from any other session, or through any other room, gets the ordinary
+404.
+
+**Why.** Revision 8's table gives the 410 as a property of the peer id
+alone. But peer ids are published in every roster, and *why* a
+participation ended — removed, standing lost, invitation expired — is
+account state about that person. Unscoped, any past room-mate could read
+it off an id they legitimately hold. This is §4.2's binding rule applied
+to the tombstone.
+
+### 15.5 §4.4, §2.5 — `/signal` refuses a bad room before the limiter
+
+**Decision.** `POST /m/{sqid}/signal` validates the room id (ten
+characters of the id alphabet) and checks that this session holds a live
+participation in that room, both **before** the rate limiter and before
+the body is read. Either refusal is the identical `404
+{"error":"not-found","boot":…}` every other refusal on the path returns.
+
+**Why.** The limiter is keyed on session and room. A session that rotated
+the room id in the URL would otherwise mint a limiter bucket per request:
+an unbounded map, an O(cap) eviction scan under the limiter's mutex, and
+an attacker-chosen string in the key and in the log line. §2.5's
+authority is reached earlier and more cheaply than the limiter, so it
+goes first.
+
+**Side effect, deliberate.** A session whose participation has already
+ended is refused before the limiter, and a detached-but-resumable session
+is not — it still holds a live participation and may keep posting.
+
+**Watch.** The limiter now sits behind a gate that takes the hub lock.
+Revisit if hub-lock hold time grows.
+
+### 15.6 §2.5 — the room-page row narrows to a non-member participation
+
+**This narrows a published contract.** Revision 8's §2.5 gives `GET
+/m/{sqid}` as "standing, or a live participation". The implementation
+reads: standing, **or a live participation whose kind is not member**.
+
+**Why.** Ruling 14 makes the participation the authority for continuing,
+and §2.4 says an admitted guest is not ended by its grant expiring — so
+a guest must keep the page. A **member** who lost the team is a different
+case: §2.5 says they are dropped by the next thing they do. If a
+member's own participation could hold the page open, an ex-member would
+keep the room for as long as the hub happened to still hold their
+participation, which is the opposite of what §2.5 promises. The fallback
+is therefore for exactly the kind ruling 14 was written for.
+
+### 15.7 §4.5 — a resume is refused the moment its window passes
+
+**Decision.** A resume arriving after `resume_until` is refused, even
+though the participation is still in the hub because the fifteen-second
+tick has not run yet.
+
+**Why.** Otherwise the window is "thirty seconds, plus however long
+until the next tick" — up to forty-five — and the number in §4.5 would
+not be the number the code enforces. The tick still does the removal;
+it is not the authority on the window.
+
+### 15.8 §4.3 — `leave` is not idempotent
+
+**Decision.** A second `leave` for the same peer answers 404. The
+participation is gone; the peer id names nothing.
+
+**Why.** The alternative — answering 200 to a leave for a peer that no
+longer exists — would be the one place on `/signal` where an unknown
+peer id is not a 404, and §4.2's "every lookup starts from the room in
+the URL, and an id from elsewhere names nothing here" would have an
+exception. The client sends `leave` once.
+
+### 15.9 §4.6 — the stale-generation 409 carries no generation
+
+**Decision.** A targeted message with a stale `gen` is refused `409
+{"error":"stale"}`, and the body does not name the current generation.
+
+**Why.** `roster.peers[].gen` is the reconciliation source §4.6 already
+specifies, and it arrives on admission, on every kind change and on
+every resume. A generation in the refusal body would be a second source
+of the same fact, able to disagree with the first, for a client that
+must handle the roster anyway.
+
+### 15.10 §4.5 — four additions to the terminal-reason table
+
+Revision 8's terminal reasons are `replaced`, `standing`, `expired`,
+`removed` and `ended`. The client adds four dead ends that the server
+never names, because a browser can reach them without the hub having an
+opinion:
+
+| Reason | Reached when | What the person is told |
+|---|---|---|
+| `signedout` | a POST to `/signal` answers 401 or 403, or is redirected to sign-in | "You have been signed out." with a link to sign in |
+| `unreachable` | the fresh-stream ladder has failed its ceiling of attempts with no `hello`, over a window as well as a count | "Meet cannot reach the call. The room may be full, or you may have been signed out." |
+| `nowebrtc` | constructing an `RTCPeerConnection` throws | "This browser cannot make calls." |
+| `gone` | a fresh stream is refused 404 for a room this browser may no longer enter | "You cannot join this meeting." |
+
+`gone`'s wording is deliberately the least informative of the four: a
+fresh 404 is §2.5's "a meeting that is not yours does not exist", so the
+sentence must not distinguish an ended meeting from one that was never
+this person's.
+
+Every terminal screen carries a way out — a link to `/m/{sqid}/join` —
+so none of them is a dead end with nothing to click.
+
+### 15.11 Recorded, not changed
+
+Two things the slice found that are not departures, kept here because
+they were otherwise recorded only in an untracked ledger:
+
+- **A just-added member gets 404 from the removal confirm page until the
+ next tick.** `roomAccess` reads fresh standing while `PeerNameFor`
+ reads the participation's cached kind, and only the tick refreshes it.
+ It self-heals within fifteen seconds, and the Remove control is not
+ drawn until the roster says member, so nobody sees the 404 without
+ going looking for it. Not worth a database read on the page path.
+- **The hub is per process and dies with it** (§4.2, §4.5, stated but
+ not solved). Slice 2 is the first release where that has a
+ user-visible cost: **every deploy drops every live call.** Members
+ rejoin automatically; guests go back to waiting and must be admitted
+ again. Deploys should avoid meeting hours until slice 3, or until a
+ drain exists.