# 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

Eleven 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 · §15.12 the
member's standing re-derived on every request (Codex round 1, §2a).

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 callstest 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**, differently from the suggestion. The reviewer proposed signing out and in again over HTTP; that does not gate the claim, because re-signing-in changes the cookie value too and the key would move with or without the subject. The derivation is now a function of its own (`hubSessionKey`) with a test that changing the subject alone, the salt alone or the cookie alone each changes the key, and that the key is neither the cookie value nor a bare hash of it. |
| 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.** `refused()`'s 400 arm and `TERMINAL.badlast` are gone, with a comment saying why they could not fire: both callers pass a probe response, the stream's only 400 is a `last` it could not parse, and neither probe form sends `last`. The attaching stream's 400 stays invisible to `EventSource` and ends as a stall, which is what already happened. The copy catalogue row is updated. |
| 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, callstest, browser |
| `rosterFor`'s admitted-only mesh | hub, callstest |
| `admitLocked`'s admitted-only `peer` fan-out | hub, callstest, browser |
| `refreshRostersLocked`'s admitted-only refresh | hub |
| `knock`'s admitted-members audience | hub, callstest, browser |

---

## 2a. Codex round 1, and what happened to it

An independent review by Codex, judged at `cfc0dc0` and applied on top
of the whole-branch fix wave (`67d0ead`). One P1, six P2s, two P3s;
verdict: request changes. Every one is fixed. Line numbers in the
original moved by a few lines between `cfc0dc0` and the wave that
applied these.

| # | Finding | Disposition | Commit | Test |
|---|---|---|---|---|
| S2-1 (P1) | A removed member keeps relay, reset and resume authority until the tick: `Relay`, `Reset`, `CanResume` and `Resume` check the participation and never re-derive standing, which contradicts §2.5's own paragraph | **Fixed.** The paragraph wins over the table row, and the row is corrected in spec §15.12. All four re-derive standing for a member-kind participation, outside the hub lock, re-find under it, and apply through `standingLostLocked` — so the admitted guest's ruling-14 exemption is untouched. The member is ended with `lost{standing}` and answered 404, byte-identical to an unknown peer's. `CanResume` answers without ending: a probe changes nothing (§15.3). | `3c8c6ac` | `TestAMemberWithNoStandingIsRefusedByTheNextRequestNotTheNextTick` (relay, reset, resume, probe, each before any tick, each checking the other stream sees `gone`) and `TestAnAdmittedGuestWithNoStandingKeepsRelayingResettingAndResuming` |
| S2-2 (P2) | A participation past its resume window is refused a resume (§15.7) but still 409s the fresh stream that refusal sends the browser to, until the tick — a false "open in another tab" | **Fixed.** `Open` ends a lapsed participation under the lock and takes its place, on the tick's own terms (`lost` unobservable, departure announced); `LiveForSession` and `LiveKindForSession` no longer count one, so the holding screen's notice and the peerless probe agree with it. Probes stay non-mutating. | `3c8c6ac` | `TestAResumePastItsWindowIsRefusedAndAFreshStreamTakesItsPlace` (renamed from `...BeforeTheTickRemovesIt`) and `TestAFreshStreamAfterALapsedResumeSucceedsBeforeAnyTick` over HTTP |
| S2-3 (P2) | `recoverFromNotFound` reads a signal 404 as this participation's ending, when it can equally mean the target left: the client closed a live call, then met 409 from its own participation and showed the takeover screen | **Fixed.** The sender's own participation is probed first: 204 keeps the call, drops that target's queue and resets only if the roster still lists it; 410 is terminal with the tombstone's reason; 404 is the fresh-entry ladder. | `5e8db6c` | browser `staletarget`: A's stream is cut, B leaves, A posts to B, A is answered 404 and stays in the call with its own peer id and no takeover screen, then reconciles from the roster |
| S2-4 (P2) | `Sub.terminate`'s eviction receive can block on a channel the reader drained, with `h.mu` held — the hub's one lock, against every room in the process | **Fixed.** The receive is non-blocking and the final send is retried once; two attempts is the ceiling. | `3c8c6ac` | `TestTerminateNeverBlocksOnAChannelTheReaderDrained` — a reader draining concurrently, a per-round deadline, and run under `-race` |
| S2-5 (P2) | `requestReset` discards the POST result, so a 429 or a 5xx loses the reset silently — and after an outbound deadline that reset is the only thing that can rebuild the pair | **Fixed.** Bounded retry inside the same thirty seconds, with `Retry-After`, the 429/5xx backoff, the signed-out arm and the 404 ladder. | `5e8db6c` | browser `resetretry`: every POST refused until the deadline fires, then the reset it asks for refused once with 429 — A asks again and the pair comes back |
| S2-6 (P2) | The outbound queue's thirty seconds is not enforced across 429s or a hanging fetch, and sleeps are not bounded by it | **Fixed.** An absolute per-message deadline covering requests, backoff and 429s, with an `AbortController` on the fetch at the deadline. | `5e8db6c` | browser `senddeadline` (continuous 429s) and `sendhang` (a route that never settles) |
| S2-7 (P2) | Perfect negotiation has no `isSettingRemoteAnswerPending`, so an offer arriving while an answer is being applied reads as glare and the impolite side discards it | **Fixed.** The W3C guard exactly, factored into `offerCollision(entry, signalingState)`. | `5e8db6c` | `test/browser/negotiation.test.mjs` lifts that function out of the shipped source and checks the truth table under `node --test`, wired into `make browser`; the `gap` and `replay` scenarios stay green |
| S2-8 (P3) | `join` and the pump process a 200 before checking `signedOut`, so a followed redirect to sign-in reads as success | **Fixed.** `signedOut` is checked first in both. | `5e8db6c` | the browser `signedout` scenario gains a second phase: `/signal` answers 303 to `/signin`, fetch follows it into a 200 HTML page, and the room ends as signed out |
| S2-9 (P3) | Two browser assertions pass vacuously: `playfallback` checks only that the button disappears, `devices` only that `srcObject` is truthy | **Fixed.** `playfallback` asserts the tile is paused before the gesture and that frames and the element's clock both advance after it — and the rig now emulates a blocking autoplay policy properly, pausing the element rather than only rejecting the promise, without which the assertion was true before the gesture too. `devices` asserts `videoWidth > 0` and a rising `currentTime`. | commit below | the two scenarios themselves |

Every test above was run against the unfixed code and seen to fail:
S2-1's four sub-cases with `memberStandingGone` stubbed to "not gone";
S2-2's with `lapsedLocked` disabled, and again with only `Open`'s use of
it disabled; S2-4's a few thousand rounds in, with and without `-race`;
S2-3, S2-5, S2-6 and S2-8's against `room.js` at `67d0ead`; S2-7's
against the old one-line decision; `playfallback`'s against a Play
handler that hides the control without calling `startPlaying` (the
mutation Codex names), which the old assertion still passed; `devices`'
against a preview handed a stream with its video track removed, which
the old truthy-`srcObject` check also still passed.

---

## 2b. Codex round 2, and what happened to it

The same reviewer, judged at `3eec748` — the round-1 wave — against spec
revision 9. Six of the nine round-1 findings are closed. Three came back
as new P2s because the fix was right and incomplete: a probe that still
wrote, a recovery that recursed, and a recovery that acted on an answer
that proved nothing. Two P3 test advisories. No new P1.

| # | Finding | Disposition | Commit | Test |
|---|---|---|---|---|
| S2-10 (P2) | `memberStandingGone(..., end=false)` honoured the flag only when standing was *absent*; with standing present it wrote the kind, so a probe could demote a member to guest — and broadcast a fresh roster to the whole room while doing it | **Fixed.** A probe writes nothing whatever the answer is. The transition is real and is applied by the next mutating request, or by the tick. | `de597c6` | `TestAProbeNeverWritesAKindNorBroadcastsARoster`: a member taken off the team while holding a live grant, probed — kind unchanged and both subscribers silent — then one relay, which demotes and refreshes both rosters |
| S2-11 (P2) | A signal 404 whose probe answered 204 asked for a reset to the stale target; that reset was answered 404 too, probed, and asked again — each round with a fresh thirty-second budget | **Fixed.** A target the hub has 404'd is marked stale: `send` drops for it, only the first round may ask for a reset, a reset's own target-404 never asks for another, one reset per target is in flight at a time, and the recovery's retries share the caller's budget. The roster clears the mark and `reconcile` rebuilds the connection if the peer is really there. | `dc614c9` | browser `staletarget` counts every POST A makes while its roster is stale and bounds it: 1 on the fixed client, **450** on the round-1 client |
| S2-12 (P2) | A probe answering 429, 5xx or nothing fell through to the fresh-entry ladder — S2-3's failure, reached by another door | **Fixed.** Only the probe's own 404 enters afresh; a transient answer is retried on the stream backoff inside the shared budget, and a recovery that never gets an answer leaves the participation alone. | `dc614c9` | browser `staletarget` refuses the recovery's probe 503 before letting it through: A keeps its peer id and never sees the takeover screen |
| S2-13 (P3) | `senddeadline` and `sendhang` accepted anything under sixty seconds, and `sendhang` never showed the request had hung or that the page aborted it | **Fixed.** Both measure from the first intercepted message and require 25–35 s. `sendhang` asserts the hung count and watches `requestfailed` for the cancellation. | commit below | the two scenarios: 30018 ms and 30238 ms on this machine, one cancelled request |
| S2-14 (P3) | `TestTheTickDeadlineIsTheTickInterval` asserted a constant equalled itself | **Fixed.** The scheduling path is `tickLoop(ctx, every)` with the pass behind a seam that is nil in production; the test runs the real loop in milliseconds and records the deadline it hands out. | `de597c6` | `TestTheSchedulingPathGivesEachPassTheIntervalAsItsDeadline` |
| Negotiation note | The six decision cases would stay green with the handler's flag assignment removed | **Fixed.** `negotiation.test.mjs` also drives `onAnswer` then `onOffer` through a fake connection whose `setRemoteDescription` can be held open. | `dc614c9` | the seventh case fails with the assignment removed |

Every test above was run against the unfixed code and seen to fail:
S2-10's with the kind write restored ("the probe wrote the kind: guest");
S2-14's with the loop passing three times its interval; S2-12's and
S2-11's against `room.js` at `3eec748` — the first ended terminal with
`peer: null`, and the second, with the 503 phase disabled so the cycle
could run at all, reached 450 POSTs in thirteen seconds. The seventh
negotiation case fails on "the answer handler must raise the flag before
it awaits".

The two round-2 client findings share one shape, and it is worth naming
because it will come round again: **a refusal is evidence about exactly
one thing.** A 404 from `/signal` is evidence about the target, not
about us; a 503 from a probe is evidence about neither. Both bugs were a
client treating an answer as if it settled more than it did.

---

## 2c. Codex round 3, and what happened to it

Judged at `f1a0f9c`. S2-10, S2-13, S2-14 and the negotiation note are
closed. S2-11 and S2-12 came back half-closed as one P2 — the shared
budget was named in the commit message and not actually shared — and one
P3 on a test predicate. No new P1.

| # | Finding | Disposition | Commit | Test |
|---|---|---|---|---|
| S2-15 (P2) | The recovery's "one budget" was three: the pump passed no deadline (a fresh thirty seconds), a successful diagnosis asked for a reset with another, and `probe()` had no cancellation, so the deadline was checked only between attempts and a hanging probe held its target's pump or reset marked in flight indefinitely | **Fixed.** The message's deadline goes from the queue head through `recoverFromNotFound` and into any reset it initiates; `probe(path, deadline)` bounds the fetch **and** the body read with an `AbortController`; the remaining time is checked before every attempt; and a recovery that runs out marks the target stale and releases it, leaving the next roster to settle it. The resume and fresh-entry probes are bounded too — a hanging probe there would have ended this browser's reconnecting. | `6bd2f00` | browser `probehang`: the 404 is answered twenty seconds late and the probe that follows never answers. The target is let go at **30236 ms** from the first intercepted signal — a restarted clock lands near 50000 — and A stays in the call |
| S2-16 (P3) | `staletarget` could call the recovery complete before the stream came back: `s.peers` is local `pcs` keys, and `staleTarget` closes that connection while the stream is still cut | **Fixed.** Reconciliation now requires a stream that actually reattached (the route's own attach counter) and a roster that actually arrived — `state()` gained a roster counter for it — before the final POST bound is checked. | commit below | the strengthened `staletarget`: attaches 1→2, rosters 1→2 |
| Negotiation note | Case seven could not tell whose cleanup it watched, because `onOffer` clears the pending-answer flag too | **Fixed.** An eighth case drives an answer with no offer behind it. | `6bd2f00` | `the answer handler clears its own flag` |
| `git diff --check` | A blank line at the end of the copy catalogue | **Fixed.** | commit below | `git diff --check` is clean |

Verified failing first: `probehang` against `room.js` at `f1a0f9c` —
the target was never let go, and the measurement read 70056 ms at the
scenario's own ceiling; the strengthened `staletarget` predicate with
the stream deliberately left cut — attaches 1→1, rosters 1→1, never
true, where the round-2 predicate was satisfied the moment
`staleTarget` closed the connection locally; the eighth negotiation
case fails with the answer handler's flag assignment removed.

Also recorded from this round, not changed: the targeted-404 assumption
behind `stale` is exact for the path the client actually walks — an
admitted sender addressing a previously admitted, different peer — and
the reviewer confirmed the server's other 404s (unadmitted sender,
unadmitted target, self-target) are not reachable from it. A stale
generation is a 409, not a 404. Slice 3's `/ice` and slice 4's chat
should re-check that list before reusing `stale`.

---

## 2d. Codex round 4, and what happened to it

Judged at `2f7f63f`. S2-16 closed. S2-15 half-closed through one P2, and
it is the same lesson a third time: the budget was threaded correctly
and then leaked out of the one exit nobody was watching.

| # | Finding | Disposition | Commit | Test |
|---|---|---|---|---|
| S2-17 (P2) | An aborted probe or a transient refusal that exhausted `waitWithin` returned from `recoverFromNotFound` without releasing the target. The queue survived, the pump's `finally` restarted it, its expired head called `unreachable`, and that opened a reset with a fresh thirty seconds — measured at 31000 for the offer and 61000 for the reset, with zero stale-target calls. Separately `resetLoop` checked the participation before every attempt but never the clock, so one POST could go out past the deadline meant to cover it | **Fixed.** Both loops round back to a single check at the top, which is now the only place a spent budget is answered, and it answers by releasing the target — queue dropped before the pump is released, connection closed, mark set, next roster settles it. `resetLoop` gained the same check. `createPC` clears the mark, because building a connection to a peer means the server has just named it. | `50953f4` | a pure node test — the recovery lifted out of the shipped source, given fifty milliseconds and a probe that never answers, must release its target once and ask for nothing — and `probehang`, which now asserts the offer was the *only* thing A posted, that it is still the only thing ten seconds later, and that A holds the target marked stale |

Verified failing first: the node test with the exhaustion `return`
restored — "the target must be released exactly once"; `probehang`
against `room.js` at `2f7f63f` — **2** POSTs where the fixed client
makes 1, and no stale mark at all. The second POST is the fingerprint of
the finding: it is the reset that ran on a fresh thirty seconds while
the scenario's earlier assertions were all passing.

`state()` gained the stale set for this, which is the third thing the
gate has needed the client to say about itself (`boot`, `rosters`,
`stale`). Each is a fact no DOM shows and no HTTP answer reveals: a
client that has *stopped* asking for something looks exactly like one
that never had to.

---

## 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/calls/hub/isolation_test.go` and
  `internal/callstest/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` | ~300 s, 23 scenarios, plus one node test (`test/browser/negotiation.test.mjs`, §5.1's glare decision and the handler race it turns on, no browser needed). 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.

**It is not perfectly stable.** One run in this fix wave had
`rosterpair` hang for 120 s and fail, on a tree where it passes in 4.5 s
alone and passed 18/18 on the run before and the run after. That
scenario cuts a stream mid-call and waits for a resume, so it has the
most timing in it of any of them. A single red `rosterpair` is worth
re-running before it is worth debugging; two in a row is not.

**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.
