oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1# Meet slice 2 — the record
2
3Branch `slice/2-room`, `77227de..` through the whole-branch fix wave.
4Spec: `docs/superpowers/specs/2026-09-07-meet-design.md`, revision 9
5(§15 is this slice's departures from revision 8).
6
7Slice 2 built the room: the hub, `POST /signal`, the SSE stream, the
8holding screen, the browser client, and a Playwright gate that makes a
9real call between two real Chromium contexts.
10
11This file exists because everything below was, until now, written down
12only in `.superpowers/`, which is gitignored. The reasoning did not
13merge and did not survive the worktree. What follows is that ledger's
14substance: the decisions, why each one was taken, what the reviews
15found, and what is still open with the slice it belongs to.
16
17---
18
19## 1. Rulings and decisions
20
21Eleven of these changed what the spec says and are written into §15 of
22the spec itself, with their reasoning. They are listed here by name
23only, so this file stays the index and the spec stays the contract:
24
25§15.1 the hub session key · §15.2 the `Accept` gate and its 406 ·
26§15.3 the `?probe=1` resume probe · §15.4 the scoped 410 ·
27§15.5 `/signal`'s refusals before the limiter · §15.6 the narrowed
28room-page row · §15.7 the resume window enforced without waiting for the
29tick · §15.8 `leave` is not idempotent · §15.9 the stale-gen 409 carries
30no gen · §15.10 the four client-only terminal reasons · §15.12 the
31member's standing re-derived on every request (Codex round 1, §2a).
32
33The rest are implementation decisions the spec has no opinion about.
34
35**The hub**
36
37- A hub method that dereferences `h.rooms[sqid]` must nil-check the
38 room and return `ErrUnknownPeer`. Cost if wrong: a nil-map panic on a
39 bad sqid.
40- A second resume of an already-attached peer replaces the attachment,
41 and the old stream is told `lost {reason: "replaced"}`. No tombstone is
42 written, so the participation itself continues. Two tabs of one
43 browser are the one-peer-per-session rule, working as intended.
44- `Kind` is written in exactly one place (`setKindLocked`) and
45 `Admitted` in exactly one (`admitLocked`), because a kind change owes
46 every admitted peer a fresh roster and a second writer would be a
47 second place to forget it.
48- `StandingFor` is never called under the hub lock. Every caller drops
49 the lock, reads, retakes it, and **re-finds** the participation,
50 because it may have ended while the read was in flight.
51- An admitted guest is never ended by a grant expiring (ruling 14): the
52 participation is the authority once someone is in. `standingLostLocked`
53 is the one place that decides what a loss of standing costs, because
54 what it costs depends on the kind held.
55- `Admit` settles the actor's authority completely — found, standing
56 applied, admitted, member — before it looks at the target at all.
57 The other order let any joined participation learn from the shape of
58 the refusal whether a candidate peer id existed in the room.
59- Headstone rooms are never reaped and `h.rooms` is never deleted from.
60 Any future reaper must wait out `TombstoneLife`; the note sits beside
61 `End`.
62- `gap` is the highest evicted `seq` per sender above `last[from]` — a
63 deviation from the Task 3 brief, which asked for a simpler test.
64
65**The signal endpoint**
66
67- The rate limiter is keyed on session + room, never on anything the
68 request body claims, and the key is built before the body is read.
69 Keyed on the claimed peer id, one session could drain another's bucket
70 (peer ids are published in rosters) or evade its own by rotating ids.
71- `peer` is 32 lowercase hex or 400, and `type` is logged only from a
72 fixed list. Attacker-chosen text never becomes a map key or a log
73 argument.
74- Hub errors other than the four sentinels are logged and answered 500.
75 `ErrNotYours` and `ErrUnknownPeer` share one byte-identical 404 body
76 including the boot id.
77
78**The pages**
79
80- The removal confirm page checks fresh member standing *and* an
81 admitted member participation (`hub.PeerNameFor`), and names the
82 person, not the meeting.
83- After a removal the confirm tab goes to `/m/{sqid}/join` — inert,
84 opens no stream — not back to the room. Both confirm pages' Cancel and
85 back links go there too.
86- The takeover confirm page 303s to `/join` when this session holds
87 nothing to take over, and re-checks standing before `hub.Takeover`.
88- Toggle buttons keep a fixed accessible name (`aria-label` Microphone /
89 Camera) with `aria-pressed`, plus a child span carrying the state text
90 — "On"/"Muted", "On"/"Off" — marked `aria-hidden`. Never colour alone,
91 and never a label that changes under the person's cursor.
92- The three device pickers use `ui/field-select`. AGENTS.md's `[x]`
93 Fields rule is hard: `devices.js` fills the vendored component rather
94 than hand-rolling an equivalent.
95- Load-time `getUserMedia` on the holding screen stands, with a
96 `visibilityState` guard and a `visibilitychange` retry: it is a page
97 the person navigated to on purpose.
98
99**The client**
100
101- Every terminal state carries a way out: a link to `/m/{sqid}/join`.
102- A stream that dies moments after its `hello` has proved nothing: the
103 backoff is cleared only by a stream that stayed open five seconds.
104- The stall ceiling is gated on time as well as count — eight stalls
105 *and* sixty seconds since the last `hello` — so a room that is merely
106 slow is not declared unreachable.
107- `terminal()` clears `admitted` and keeps `peer`: the peer id names
108 what this tab held, which is worth having in a diagnosis, and nothing
109 reconnects with it once `stopped` is set.
110
111**The gates**
112
113- The browser gate reports a client failure as `t.Skip` carrying the
114 exact text, never by deleting or loosening a scenario.
115- The buffer-overrun scenario pins `SO_SNDBUF` through a custom
116 listener: without it the gate depended on the machine's `tcp_wmem`.
117- The renewal scenario is production-shaped — a six-second TTL through
118 the development-only `X-Meet-Test-TTL` header, with a floor of four —
119 rather than lowering the client's `HEALTHY_STREAM`.
120
121---
122
123## 2. Whole-branch review findings, and what happened to them
124
125Whole-branch review at `cfc0dc0`: 53 files, 13,338 insertions, all three
126gates green, 48 mutations applied to a throwaway copy — 40 killed, 8
127survived, every survivor a test gap rather than a bug. Verdict: merge
128with W-1 and W-2 fixed first.
129
130| # | Finding | Disposition |
131|---|---|---|
132| 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. |
133| 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. |
134| 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. |
135| 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. |
136| 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. |
137| 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. |
138| W-7 | `lost{buffer}` is sent for three endings that are not buffer overruns | **Carried — next fix wave.** See §3. |
139| 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. |
140| W-9 | Three wordings for one move | **Carried — the copy pass.** Already in the tracked copy catalogue. |
141| W-10 | The test hook ships to production | **Recorded, not fixed.** See §3. |
142| W-11 | Three hub maps only ever grow | **Recorded, not fixed.** See §3. |
143| 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. |
144
145Mutation survivors the reviewer reported, and where each is killed now:
146
147| Predicate | Killed by |
148|---|---|
149| `rosterFor`'s member-only waiting queue | hub, callstest, browser |
150| `rosterFor`'s admitted-only mesh | hub, callstest |
151| `admitLocked`'s admitted-only `peer` fan-out | hub, callstest, browser |
152| `refreshRostersLocked`'s admitted-only refresh | hub |
153| `knock`'s admitted-members audience | hub, callstest, browser |
154
155---
156
157## 2a. Codex round 1, and what happened to it
158
159An independent review by Codex, judged at `cfc0dc0` and applied on top
160of the whole-branch fix wave (`67d0ead`). One P1, six P2s, two P3s;
161verdict: request changes. Every one is fixed. Line numbers in the
162original moved by a few lines between `cfc0dc0` and the wave that
163applied these.
164
165| # | Finding | Disposition | Commit | Test |
166|---|---|---|---|---|
167| 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` |
168| 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 |
169| 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 |
170| 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` |
171| 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 |
172| 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) |
173| 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 |
174| 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 |
175| 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 |
176
177Every test above was run against the unfixed code and seen to fail:
178S2-1's four sub-cases with `memberStandingGone` stubbed to "not gone";
179S2-2's with `lapsedLocked` disabled, and again with only `Open`'s use of
180it disabled; S2-4's a few thousand rounds in, with and without `-race`;
181S2-3, S2-5, S2-6 and S2-8's against `room.js` at `67d0ead`; S2-7's
182against the old one-line decision; `playfallback`'s against a Play
183handler that hides the control without calling `startPlaying` (the
184mutation Codex names), which the old assertion still passed; `devices`'
185against a preview handed a stream with its video track removed, which
186the old truthy-`srcObject` check also still passed.
187
188---
189
190## 2b. Codex round 2, and what happened to it
191
192The same reviewer, judged at `3eec748` — the round-1 wave — against spec
193revision 9. Six of the nine round-1 findings are closed. Three came back
194as new P2s because the fix was right and incomplete: a probe that still
195wrote, a recovery that recursed, and a recovery that acted on an answer
196that proved nothing. Two P3 test advisories. No new P1.
197
198| # | Finding | Disposition | Commit | Test |
199|---|---|---|---|---|
200| 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 |
201| 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 |
202| 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 |
203| 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 |
204| 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` |
205| 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 |
206
207Every test above was run against the unfixed code and seen to fail:
208S2-10's with the kind write restored ("the probe wrote the kind: guest");
209S2-14's with the loop passing three times its interval; S2-12's and
210S2-11's against `room.js` at `3eec748` — the first ended terminal with
211`peer: null`, and the second, with the 503 phase disabled so the cycle
212could run at all, reached 450 POSTs in thirteen seconds. The seventh
213negotiation case fails on "the answer handler must raise the flag before
214it awaits".
215
216The two round-2 client findings share one shape, and it is worth naming
217because it will come round again: **a refusal is evidence about exactly
218one thing.** A 404 from `/signal` is evidence about the target, not
219about us; a 503 from a probe is evidence about neither. Both bugs were a
220client treating an answer as if it settled more than it did.
221
222---
223
224## 2c. Codex round 3, and what happened to it
225
226Judged at `f1a0f9c`. S2-10, S2-13, S2-14 and the negotiation note are
227closed. S2-11 and S2-12 came back half-closed as one P2 — the shared
228budget was named in the commit message and not actually shared — and one
229P3 on a test predicate. No new P1.
230
231| # | Finding | Disposition | Commit | Test |
232|---|---|---|---|---|
233| 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 |
234| 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 |
235| 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` |
236| `git diff --check` | A blank line at the end of the copy catalogue | **Fixed.** | commit below | `git diff --check` is clean |
237
238Verified failing first: `probehang` against `room.js` at `f1a0f9c` —
239the target was never let go, and the measurement read 70056 ms at the
240scenario's own ceiling; the strengthened `staletarget` predicate with
241the stream deliberately left cut — attaches 1→1, rosters 1→1, never
242true, where the round-2 predicate was satisfied the moment
243`staleTarget` closed the connection locally; the eighth negotiation
244case fails with the answer handler's flag assignment removed.
245
246Also recorded from this round, not changed: the targeted-404 assumption
247behind `stale` is exact for the path the client actually walks — an
248admitted sender addressing a previously admitted, different peer — and
249the reviewer confirmed the server's other 404s (unadmitted sender,
250unadmitted target, self-target) are not reachable from it. A stale
251generation is a 409, not a 404. Slice 3's `/ice` and slice 4's chat
252should re-check that list before reusing `stale`.
253
254---
255
256## 2d. Codex round 4, and what happened to it
257
258Judged at `2f7f63f`. S2-16 closed. S2-15 half-closed through one P2, and
259it is the same lesson a third time: the budget was threaded correctly
260and then leaked out of the one exit nobody was watching.
261
262| # | Finding | Disposition | Commit | Test |
263|---|---|---|---|---|
264| 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 |
265
266Verified failing first: the node test with the exhaustion `return`
267restored — "the target must be released exactly once"; `probehang`
268against `room.js` at `2f7f63f` — **2** POSTs where the fixed client
269makes 1, and no stale mark at all. The second POST is the fingerprint of
270the finding: it is the reset that ran on a fresh thirty seconds while
271the scenario's earlier assertions were all passing.
272
273`state()` gained the stale set for this, which is the third thing the
274gate has needed the client to say about itself (`boot`, `rosters`,
275`stale`). Each is a fact no DOM shows and no HTTP answer reveals: a
276client that has *stopped* asking for something looks exactly like one
277that never had to.
278
279---
280
281## 3. Carried items, and where they go
282
283**Next fix wave**
284
285- **W-7, `lost{buffer}` for three endings that are not buffer
286 overruns** (`admission.go`'s `Leave`, `hub.go`'s unjoined timeout and
287 lapsed resume window). `buffer` is the one reason the wire contract
288 defines as *not* terminal — "followed by a resume". Today this is
289 harmless because `room.js` sets `stopped` and closes the stream before
290 the event can be read, but the safety lives entirely in the client, and
291 §8's drive surface will have other callers. A peer whose join keeps
292 failing can loop: unjoined timeout → `lost{buffer}` → resume → 404 same
293 boot → fresh stream → join → thirty seconds → repeat, and the stall
294 counter never fires because each round gets a `hello`. **Not fixed here
295 because either fix changes the wire contract** — a new terminal reason,
296 or an ending that sends no event at all — and this wave took no
297 wire-contract changes. Fix it with the client change in the same
298 commit, and re-run the browser gate.
299- **W-10, the test hook in the release binary.** `window.meet.state()`
300 is defined unconditionally and `TestHandle` compiles into the release
301 build (unreachable without `NewForTest`, which `main` never calls).
302 Neither exposes anything secret, and the CSP keeps third-party script
303 off the page. Confining either one costs more than it buys today:
304 build-tagging `TestHandle` means `make ci` grows a `-tags` argument,
305 and gating `window.meet` on the environment means the browser gate
306 tests a page production does not serve.
307- **W-11, three hub maps that only grow.** `h.rooms` is never deleted
308 from (declared; `End`'s comment explains why a reaper is dangerous).
309 `h.bySession[sqid]` keeps an empty inner map per room forever, and
310 `h.byPerson` a zero entry per person forever. All three are bounded by
311 real meetings and real people — an outsider cannot mint a room id that
312 reaches `Open`, because standing is checked first — so this is a slow
313 leak on a long-lived process, not an attack surface. It is also
314 invisible while every deploy empties the hub (§4). Fix it with, or
315 after, whatever ends that.
316- **A just-added member gets 404 from the removal confirm page until the
317 next tick.** Recorded in spec §15.11.
318- **Watch: the limiter now sits behind a gate that takes the hub lock.**
319 Revisit if hub-lock hold time grows. Recorded in spec §15.5.
320
321**The copy pass**
322
323- W-9's three wordings for one move: "Use this tab instead" (holding
324 notice and `room.js`'s `wayOut`), "Use this tab" (takeover confirm
325 submit), and "This meeting is already open in another tab" versus
326 "This meeting is open in another tab."
327- The holding notice's "already open in another tab" is untrue during
328 the resume window; "You are already in this meeting from this browser."
329 is the candidate.
330- "Remove `<name>` from the call?" reads oddly for a guest who is not in
331 the call yet.
332
333All three are in `docs/superpowers/copy/2026-09-10-slice-2-strings.md`.
334
335**Slice 3 — relay and `/ice`**
336
337- `createPC` passes `{ iceServers: [] }` (`room.js`), with the floor
338 notice keyed on `cfg.RelayConfigured`. Those are the two places to
339 change.
340- `GET /m/{sqid}/ice` is §2.5's "participation, admitted" row — the same
341 authority as a non-`join` signal. `hub.Find` plus `p.Admitted` is the
342 whole check; do not consult standing (ruling 14).
343- Renewal (§5.3) needs a timer that survives a resume. The browser gate
344 already has a `renewal` scenario for the *stream* TTL using the
345 development-only `X-Meet-Test-TTL` header; copy that shape for the
346 credential rather than inventing a second one.
347- ICE restart already has a client path: `restartIce()` with
348 `requestReset` as the fallback, and the `replay` scenario asserts
349 `have-local-offer` to prove it fires.
350- W-12 is now bounded, but `/ice` adds a per-request credential mint on
351 a path that already does a database read. Watch the tick's deadline.
352
353**Slice 4 — chat and screen share**
354
355- `chat` and `share` are already named in `messages.go` and refused with
356 400 in `signal.go`. Removing that arm and adding the fan-out is the
357 whole server change; `broadcastLocked(r, m, admittedPeers)` is the
358 audience.
359- §4.4's 2 KB chat cap has no code yet. It must be checked against the
360 decoded text, not the body, and it must not read the text for anything
361 else — the captured-log test extends to *accepted* chat in slice 4,
362 which is the first time an accepted message has content worth
363 protecting.
364- W-1 is fixed, so chat's "arrives at the admitted context and not at a
365 waiting one" is now two lines: extend
366 `internal/calls/hub/isolation_test.go` and
367 `internal/callstest/isolation_test.go`, which exist for exactly this.
368- Screen share adds a second video track per peer. `tile()` keys on peer
369 id and `renderTiles` rebuilds from `pcs`, so the tile model needs a
370 track dimension **before** the share arrives, not after.
371
372---
373
374## 4. Deploying this slice
375
376**Every deploy drops every live call.** The hub is per process and dies
377with it. This is spec'd (§4.2, §4.5) and not solved: slice 2 is the
378first release where it has a user-visible cost.
379
380What a deploy does to people who are in a meeting:
381
382- every browser's resume is refused with a 404 carrying the new boot id;
383- each one closes every peer connection, then joins afresh;
384- **members** re-enter at once and negotiate again — a few seconds of
385 black tiles;
386- **guests go back to waiting** and must be admitted again by a member.
387
388So: **deploy outside meeting hours** until slice 3, or until a drain
389exists. There is no partial mitigation worth the words — the participants
390are in memory, and the memory goes.
391
392Nothing else about a deploy is unusual. Migrations apply at boot, once
393each; `GET /healthz` and `GET /api/version` are answered by the framework
394outside app middleware.
395
396---
397
398## 5. The gates, and how to run them
399
400| Gate | Command | Notes |
401|---|---|---|
402| The one gate | `CGO_ENABLED=0 make ci` | vet, gofmt, tests, migration check. Cgo-free, as AGENTS.md requires. |
403| 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. |
404| 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**. |
405
406`make browser` is deliberately not part of `make ci`: the one gate the
407team runs on every push must not depend on a toolchain the app itself
408does not use. Run it before every review that touches a page or the
409client. Nothing enforces that, which is a known weakness and the reason
410this row is here rather than in a comment.
411
412**It is not perfectly stable.** One run in this fix wave had
413`rosterpair` hang for 120 s and fail, on a tree where it passes in 4.5 s
414alone and passed 18/18 on the run before and the run after. That
415scenario cuts a stream mid-call and waits for a resume, so it has the
416most timing in it of any of them. A single red `rosterpair` is worth
417re-running before it is worth debugging; two in a row is not.
418
419**The race step in CI.** `.amadan/ci.d/50-race` runs `make race` when
420the runner has a C compiler and prints a skip line when it does not. The
421guard is there because the cloud runner's provisioning
422(`amadan/docs/ci-cloud-runner.md`) installs the Go toolchain and Node and
423never names a C compiler, and a step that fails on every push would be
424worse than no step. **When somebody confirms `cc` on the runner, delete
425the guard** — a race gate that can silently skip is half a gate.
426