oficina / meet Public

docs: slice 4 record and spec notes

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 26c022b7ed74ca8b7cf988d7632a6291f5342aae parent 8797ea9
2 files changed, +410 −0
  • docs/superpowers/reviews/2026-09-10-slice-4-record.md +289 −0
  • docs/superpowers/specs/2026-09-07-meet-design.md +121 −0

CI passed — run details

diff --git a/docs/superpowers/reviews/2026-09-10-slice-4-record.md b/docs/superpowers/reviews/2026-09-10-slice-4-record.md
new file mode 100644
index 0000000..dac581b
--- /dev/null
+++ b/docs/superpowers/reviews/2026-09-10-slice-4-record.md
@@ -0,0 +1,289 @@
+# Meet slice 4 — the record
+
+Branch `slice/4-chat`, `3420396..8797ea9` (started on main `3420396`).
+Spec: `docs/superpowers/specs/2026-09-07-meet-design.md`, revision 9
+(§15.15 and §15.16 carry this slice's two wire/behaviour rulings).
+
+Slice 4 built chat and screen share: `hub.Fanout`, `/signal`'s `chat`
+and `share` dispatch, the chat panel as estilo's first adoptable
+component, `room.js`'s broadcast lane and screen-share transceiver, and
+the browser gate's `chat`, `share` and `mutualshare` scenarios — six
+tasks, each with its own review and fix rounds, kept in
+`.superpowers/sdd/2026-09-10-meet-slice-4-chat/progress.md` and the
+`task-N-report.md` files. Like slice 2's and slice 3's, that ledger is
+gitignored and does not survive the worktree; this file is its
+substance.
+
+---
+
+## 1. Rulings and decisions
+
+From the ledger's pre-flight scan and the tasks that followed it:
+
+- **The fan-out excludes the sender; the panel echoes locally on
+ acceptance.** **Ruling.** Cost if wrong: a duplicate line in the
+ sender's own panel. Written into spec §15.15.
+- **Chat above 2048 bytes is `413 too-large`; every other malformed
+ shape — an empty text, an extra key, a non-bool `on`, a `to` on
+ either type — is `400 bad-request`.** **Ruling.** Cost if wrong:
+ none observable beyond which sentence the client's status line
+ shows. Written into spec §15.15.
+- **`share` carries only `{on}`; the tile's label is the sender's name
+ from the roster and the envelope, never a track label.** One sharer
+ at a time is not enforced — §5.4 already says so. Written into spec
+ §15.15.
+- **The size and UTF-8 checks run on the raw wire bytes, capped at
+ 2048 + 32, not on the decoded field.** Found the hard way: Task 2's
+ first pass measured `len(*p.Text)` after decoding, which a
+ duplicated `"text"` key (JSON keeps only the last) or a run of
+ `\uXXXX` escapes (roughly a sixth of its raw size once decoded) both
+ defeat while the *raw* bytes — exactly what `Fanout` forwards
+ unread — stay large. Fixed in Task 2's fix round: `len(raw) >
+ maxChatBytes+32` before decoding, `utf8.Valid(raw)` on the same raw
+ bytes rather than `utf8.ValidString` on the decoded string (which a
+ scratch test proved is always valid, since `encoding/json` repairs
+ an invalid byte with U+FFFD on the way into a Go string — checking
+ the decoded value would let a genuinely invalid byte through while
+ reporting the payload clean). Written into spec §15.15.
+- **The gate drives `getDisplayMedia` with
+ `--auto-accept-this-tab-capture` alone.** Settled by a throwaway
+ probe before a line of Task 5's scenario was written: the existing
+ `--use-fake-ui-for-media-stream` flags already accept display
+ capture in this Chromium over `http://127.0.0.1` (a `data:` URL is
+ not a secure context and fails for an unrelated reason), so the
+ brief's fallback flag,
+ `--auto-select-desktop-capture-source="Entire screen"`, was never
+ needed. Kept anyway, belt and braces, because it is the documented
+ flag for the job and costs nothing.
+- **The polite side of a negotiation waits 300 ms before offering,
+ once per negotiation (`POLITE_YIELD`).** Found in Task 4's second fix
+ round, after the prescribed fix (releasing an unassociated share
+ transceiver before a rollback) was measured twice and shown not to
+ touch the actual cause. Chromium rebuilds every send and receive
+ stream on a connection — not only the m-line a rollback removes —
+ when it rolls back an offer that added an m-line, which is exactly
+ what two peers sharing a screen at the same instant does to the
+ polite side; the camera's sender comes back on the same SSRC having
+ encoded nothing, ever. An offer answered from `stable` needs no
+ rollback, which is what the yield buys. Measured at each step: yield
+ on the share path alone, 1 failure in 3; yield on every negotiation,
+ 1 failure in 4; bounded to once per negotiation, 0 in 6, then 0 in 8
+ more, then 30/30 twice in the full gate. Written into spec §15.16.
+- **`regainShare` and `shareTxLost`, added in Task 4's first fix round
+ to put a screen back after a rollback, were both no-ops where they
+ stood — found and removed in the second round.** `regainShare` called
+ `addShareTo`, which the same round had just taught to refuse
+ anything but a stable connection, and `onOffer` at that point is
+ `have-remote-offer`; it could never add anything. `shareTxLost` asked
+ whether the transceiver was stopped or had lost the local track,
+ which a rollback does neither — it un-associates a transceiver
+ `addTransceiver` created, and the display track stays on its sender.
+ The test that "discriminated" for the first round only proved a
+ stub was *called* in the right order, never that calling it did
+ anything. Not written into the spec: an implementation dead end, not
+ a decision the spec has an opinion about — recorded here so the
+ false path is not walked twice.
+- **`classifyTrack`'s four-signal order** — the receiving transceiver's
+ own direction, then the camera's mid, then the camera's stream id,
+ then the peer's other-media stream id, and only then a tiebreaker —
+ is an implementation decision the spec has no opinion about (§5.4
+ says a peer's screen becomes a second tile; it does not say how a
+ mesh peer tells a screen from a camera on the wire). Found necessary
+ in Task 4's first fix round once the reviewer showed the blind spot
+ the first draft's report had wrongly called unreachable: a peer with
+ no camera (the audio-only rung of `startLocal`'s fallback ladder)
+ really can have its screen be the first video track its peers ever
+ see.
+- **The one hole the classifier still has is documented, not closed.**
+ A peer with no camera *and* no microphone has sent nothing before
+ its screen — no camera mid, no camera stream, no peer stream — and
+ if the receiving tab also has no camera, direction says nothing
+ either. Classification then rests entirely on the `share` message,
+ which §4.3 delivers or reports as failed. Decided as acceptable
+ rather than closed: see §3 below.
+- **`required` is dropped from the chat textarea rather than routing
+ Enter through `form.requestSubmit()`.** Task 3's fix round: this
+ form never reaches a server, so `required` buys exactly one thing —
+ the browser's own validation-bubble sentence on an empty click of
+ Send, worded and toned by the browser rather than this app's copy
+ list, and untranslatable by it. Both submit paths (Send, Enter) now
+ return silently on an empty or whitespace-only box, which agrees
+ with every other product's chat box and adds no string at all.
+
+---
+
+## 2. Per-task review dispositions
+
+| Task | Built | Review | Fix rounds | Final |
+|---|---|---|---|---|
+| 1 — `hub.Fanout` | `Fanout`, `ErrNotFanout`; three tests covering delivery-and-exclusion, sender authority, and a standing loss mid-fanout | Needs fixes, tests only: the type guard was proven against one targeted type rather than as an allowlist; the envelope-rebuild test forged fields that were already zero rather than a fully forged input; §6's "never replayed" was proven only for an attached recipient, never through a detach-and-resume | Round 1 (`fa8b40d`): all three fixed and mutation-verified — a denylist-shaped guard, a patch-in-place bug, and a queue-on-detach bug were each written, run against the new tests, and shown to fail exactly the test meant to catch it | Complete. |
+| 2 — `/signal` dispatches `chat`/`share` | `validateFanout`, `fanoutError`, `maxChatBytes`, the dispatch arm; three new tests plus the captured-log test extended to an accepted and an oversize chat | Needs fixes: HIGH — the byte cap and UTF-8 check ran on decoded fields, not the raw bytes `Fanout` forwards, letting a duplicated key or an escape run through oversized; MEDIUM — the oversize refusal's log line was never proven text-free; LOW — the waiting-guest silence test had no positive control; LOW — refusal bodies (not just status codes) untested; LOW — a doc-comment gap on `signalJSON`'s normalisation | Round 1 (`012fb78`): all five fixed. The HIGH fix reverted and re-tested against the pre-fix code: four of five new subtests failed with 200 instead of 413/400, confirming the bug was real | Complete. |
+| 3 — the chat panel | `templates/partials/chat.html`, `static/chat.js`/`chat.css`, `render.go`'s partial parsing, `mount(root, {name, send}) → {append, setEnabled, clear}` | Needs fixes, small: no test that an app partial cannot shadow a vendored `ui` partial name; `aria-describedby` unasserted; the echo fired on any truthy `send` result, not strictly `true`; an edited draft did not clear "Could not send"; the log did not compensate scroll position when trimming for a reader scrolled up; script load order (chat.js before room.js) unasserted; inconsistent empty-send behaviour between Enter and the button; no `title` attribute on the timestamp; `MAX_LINES`/`MAX_BYTES` retyped rather than pinned from source; no `mount()` test against a fake DOM | Round 1 (`43d381c`): all nine items fixed; `chat.test.mjs` grew from 11 to 28 tests, most driven by constants read out of the shipped source rather than retyped. `room.html` carried no diff this round, so `make browser` was not re-run, per the coordinator's own condition | Complete. |
+| 4 — `room.js`: broadcast lane, screen share | The broadcast lane (`chat`), one `sendonly` transceiver per connection for `share`, `classifyTrack`, the share tile, the Share button | Needs fixes: MEDIUM — a second click while the `getDisplayMedia` picker was open took an un-stopped second capture; MEDIUM — a broadcast message queued during a 404 recovery that ended the session was never answered; MEDIUM — the classifier's blind spot was reachable through the audio-only fallback rung, not only theoretical; LOW-MEDIUM — mutual sharing could collide on m-line matching; LOW — a no-op mount (missing DOM hooks) still un-hid the panel; LOW — the `getStats` frame fallback could credit the wrong tile with two video streams; LOW — a 404 with the thirty-second budget already spent left no time to diagnose | Round 1 (`a7c91d3`): nine of ten items fixed outright; the mutual-share item shipped `regainShare`/`shareTxLost`, later shown to be no-ops (see §1). Re-review after round 1: nine of ten closed, item 4 (mutual sharing) not — the two functions could not do what the report claimed, the stable guard on `addShareTo` was untested, and the report overclaimed a discriminating test that only proved a stub was called. | Round 2 (`8797ea9`): root cause found (Chromium rebuilds every stream on a rollback of an offer that added an m-line); fix is `POLITE_YIELD`, 300 ms, once per negotiation; `regainShare`/`shareTxLost` removed; `negotiate()` extracted as a named, catchable function; 16 new node tests. Re-review after round 2: **Clean** — eight items carried to the fix wave rather than fixed on this branch; see §3. |
+| 5 — the browser gate: `chat`, `share` | The two scenarios; `state().chat`/`sharing`/`shares`; `diagnose()`'s per-connection reading; the `--auto-accept-this-tab-capture` launch flag | Needs fixes, minor: the guest-silence checks needed a positive control (guarded on `counted`); no-echo and no-duplicate needed pinning with exact counts, not `>=`; both members named "paul" made a name assertion untestable; `shareCaption` untested outside the browser; `chatLog` read an id rather than the data-attribute contract; `diagnose()` said nothing about the media itself (inbound-rtp, outbound-rtp, `displaySurface`); not every run's log was kept; the reported check tallies were wrong | Round 1 (`a11ad9a`): all items fixed; member B renamed to `bea@rslw.com`; `shareCaption` lifted into `room-share.test.mjs`; `diagnose()` gained `inbound`/`outbound`/`displaySurface`, later also mids and `currentDirection` on transceiver strings. The round also added `mutualshare`, written to decide the mutual-sharing question rather than assume it — it found the real defect and was shipped **skipped**, with the exact failure and every check that still passed recorded | Un-skipped as part of Task 4's second fix round (`8797ea9`), once the client-side cause was fixed; no assertion in the scenario was touched. Complete; the gate is 30 scenarios. |
+| 6 — this record and spec §15.15/§15.16 | Documentation only | — | — | This commit. |
+
+---
+
+## 3. Carried items, and where they go
+
+- **The no-media sharer hole in the classifier is documented, not
+ closed.** A peer that joined with no camera and no microphone has
+ sent nothing before its screen — no camera mid, no camera stream, no
+ peer stream to compare against — and if the receiving tab also has
+ no camera, the transceiver-direction signal says nothing either.
+ Classification then rests entirely on the `share` message.
+ **Destination: accepted as a known limitation**, backstopped by
+ §4.3's own delivery guarantee on `share` — not a bug this slice owes
+ a fix for, and named in `classifyTrack`'s own comment so the next
+ reader finds it there first.
+- **`dropShare` and `shareCaption`, now pinned.** Task 5's first report
+ flagged both as untested by anything but the browser gate, since
+ that task cannot mutate `room.js` while a reviewer reads it
+ concurrently. `shareCaption` was lifted into `room-share.test.mjs` in
+ Task 5's fix round (the caption text, that it carries no ASCII
+ apostrophe, and that a name with a space goes in whole); `dropShare`
+ was pinned in Task 4's second fix round (clears the label, the media
+ and the mid; twice is the same as once; redraws the grid once, not
+ twice; a screen announced but never arrived is still dropped).
+ **Destination: done**, both closed on the branch.
+- **`diagnose()` reading inbound-rtp.** Carried from Task 5's first
+ report (mutation testing against the client was impossible, and its
+ `diagnose()` said nothing about the media itself) into Task 5's fix
+ round: per connection, `inbound` (ssrc, `framesReceived`,
+ `framesDecoded`, `framesDropped`, `pliCount`, `freezeCount`, the
+ track's own `muted`) and `displaySurface`, then `outbound` and
+ mid-plus-direction transceiver strings added while diagnosing
+ `mutualshare` itself. **Destination: done** — this reading is exactly
+ what let Task 4's second fix round read the root cause off two
+ connections' own stats rather than guess at it.
+- **The 300 ms polite-yield residual.** Task 4's second fix round
+ closed the mutual-share defect with a 300 ms wait, not a proof: a
+ pair whose signalling round trip exceeds 300 ms can still collide,
+ and on that pair the rollback still costs the polite side its
+ camera. Nothing detects or repairs a camera that has already been
+ rebuilt. **Destination: watched, not fixed further this slice** —
+ recorded in spec §15.16 as the first thing to check if a real
+ deployment ever reports a camera stopping after two people share at
+ once, and the second thing to check is whether Chromium has fixed
+ the rollback itself.
+- **The `make browser` flake rate and its logs.** Across this slice's
+ runs: Task 4's first pass, 26/27 then 27/27; Task 4's first fix
+ round, 28/29 then 29/29; Task 5's first pass, three of four runs each
+ lost one *different* scenario (`senddeadline`, `share`, `icefail`) to
+ the stall before a clean 29/29; Task 5's fix round exposed the real
+ `mutualshare` defect (not a flake — 3 failures in 5, always the same
+ camera signature) and shipped it skipped, then a clean 29/1-skip;
+ Task 4's second fix round ran clean, 30/30 twice, 447 s each. Roughly
+ half of this slice's `make browser` runs lost exactly one scenario to
+ the environment's own media stall, and every stalled scenario passed
+ alone immediately afterward. **Destination: unchanged from slice 3**
+ — a red `make browser` run is worth a second run before it is worth
+ debugging. Every run's log this slice was kept under `$TMPDIR`
+ (`task5-browser-run3.log` … `task5-fix1-three-scenarios.log`,
+ `task5-mutualshare-try*.log`), not committed; §5 below gives the
+ stall's signature.
+- **What the Task 4 re-review named, landing clean itself.** The
+ re-review of fix round 2 (`8797ea9`) returned **Clean** — nothing on
+ the branch itself needs another round — and carried eight items to
+ the fix wave rather than to this branch, each to be measured against
+ `mutualshare` when it is picked up: (1) `entry.yielded` is cleared
+ only by this side's own successful offer, so a lost yield whose
+ remote exchange happens to satisfy the same need leaves the polite
+ side unprotected next time — clear it on any stable return, not only
+ this side's own, bounded by a counter so it still cannot become a
+ habit; (2) a detect-and-repair path for a camera already rebuilt —
+ outbound-rtp `framesEncoded` static on the camera's mid while the
+ track is live and the m-line is sending → `replaceTrack(null)` then
+ `replaceTrack(track)`, followed by `requestReset`; (3) evaluate
+ reserving the screen's m-line at `createPC` itself (an inactive
+ transceiver from the start; starting a share becomes a
+ `replaceTrack` plus a direction change) so that starting a share
+ never renegotiates a new m-line into existence at all; (4) settle
+ whether the true trigger is "a rollback that added an m-line"
+ specifically or "any rollback once media is already flowing" — two
+ simultaneous ICE restarts with no share involved is the case that
+ would tell them apart; (5) file the defect against Chromium itself,
+ with the stats trace this slice captured; (6) widen the ordinary
+ negotiation scenarios to assert frames keep moving afterwards, not
+ only at connection; (7) two peers *stopping* a share at the same
+ instant is exactly as ungated as two starting one, and untested;
+ (8) pin `POLITE_YIELD` from the shipped source rather than retype
+ it, and correct one claim already written down: the report's "the
+ yield ends early" reads as a race that resolves on its own, when the
+ code is an unconditional sleep gated by a state check taken after it
+ — accurate for what ships, but the sentence describing it overstates
+ how the two interleave. **Destination: the fix wave**, alongside a
+ reviewer's own draft of a further spec note for §5.1 on the yield,
+ which this record does not reproduce — read it in the ledger
+ (`progress.md`) alongside the whole-branch review's own findings and
+ its handoffs for the Codex round and for slices 5 and 6.
+- **Codex round 1, launched on this branch after this ledger entry.**
+ `codex exec` was launched against `8797ea9` as this record was being
+ written; nothing from it had landed by the time this commit was
+ made. **Destination:** its own entry in this file (or the next
+ slice's), the way slice 3's Codex rounds were appended to that
+ slice's own record after the fact — not fabricated here ahead of the
+ result.
+
+---
+
+## 4. Deploying this slice
+
+**No schema change.** Nothing in this slice touches `models.go`,
+`migrations.go` or `migrations/` — chat and share are both delivered
+messages, never rows (`git diff 3420396..8797ea9 -- internal/meet/migrations
+internal/meet/models.go` is empty). Deploying this slice is an
+ordinary rolling deploy with no migration step to sequence around it.
+
+**Every deploy still drops every live call**, exactly as slice 2's
+record already states (spec §15.11 — the hub is per process and dies
+with it) and slice 3's did not change: this slice adds nothing to the
+hub's own durability. A chat message in flight at deploy time is not
+queued or recovered; it is simply not delivered, the same as an offer
+would be. Members rejoin automatically; guests go back to waiting.
+
+**Chat is not stored**, on this instance or any other: §6's "not
+stored, not replayed" holds all the way down to `Fanout`, which
+delivers to an attached subscriber or not at all. There is nothing in
+a database backup, a log line, or a crash dump that a deploy could
+lose that chat did not already lack the moment it was sent — the
+property a deploy dropping a call would otherwise threaten does not
+apply here, because there was never anything held to drop.
+
+No test on this branch touches a real deployment window; the gates
+below prove the wire and the browser behaviour against fixtures and a
+local pair of Chromium contexts, the same shape slice 2 and slice 3
+used.
+
+---
+
+## 5. The gates, and how to run them
+
+| Gate | Command | Notes |
+|---|---|---|
+| The one gate | `CGO_ENABLED=0 make ci` | vet, gofmt, tests, migration check — unchanged from slice 3. |
+| Race | `CGO_ENABLED=1 go test -race ./internal/...` | Run through this slice's fix rounds; green throughout. |
+| Node | `node --test test/browser/{negotiation,chat,room-share,room-lane}.test.mjs` | 164 tests by the branch head, 0 failing; every assertion added this slice was watched failing against a deliberately broken source first, except Task 5's — a reviewer reads `room.js` concurrently through that task, so its evidence is the runs themselves: three name assertions failed against the wrong expected value before passing against the right one, and a media stall failed exactly the frame-counting checks and none of the message-driven ones. |
+| Browser | `make browser` | 30 scenarios, three of them (`chat`, `share`, `mutualshare`) new this slice. **Needs the sandbox off** — inside the Claude Code bash sandbox Chromium gathers zero ICE candidates and `getDisplayMedia` produces a source with nothing crossing the connection, as slice 2's and slice 3's records already note. `getDisplayMedia` in headless Chromium additionally needs `--auto-accept-this-tab-capture`; `--use-fake-ui-for-media-stream` covers `getUserMedia` only. **The run takes about 7.5 minutes** — 447 s measured twice, clean, in Task 4's second fix round, against 423-424 s for the 29-30 scenarios before it; the two new message-driven scenarios (`chat`, `share`) together cost only about 5.4 s of that, since both are written entirely on `until`/`stayed` deadlines and neither sleeps. |
+
+**The one-scenario media stall, and its signature.** Roughly half of
+this slice's `make browser` runs lost exactly one scenario — a
+different one each time, never the same twice in a row — to the
+environment's own known symptom, unrelated to anything this slice
+built (the Makefile's own comment above `browser:` names it, and
+slice 3's record already caught it against the ICE scenarios). The
+signature, quoted from one losing run: `connection: "connected"`,
+`ice: "connected"`, both tracks reporting `"live"`, `transceivers:
+["sendrecv:audio","sendrecv:video"]` — and `frames: {}`. The transport
+is up, negotiation is done, and no pixels ever move; every stalled
+scenario this slice hit passed alone, immediately, on a second try.
+This is not the stale-roster defect slice 3 fixed (whose signature was
+`peers: []`) and it is not the `mutualshare` defect this slice fixed
+(whose signature was two live video streams with one encoding zero
+frames) — it is the environment stall both of those records already
+name, reached by a third scenario each time.
+
+`make browser` is still deliberately outside `make ci`, for the reason
+slice 2's record already gives. As with slice 3's residual flake, a red
+run here is worth a second run before it is worth debugging.
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md
index 9e2ff51..cf0c14f 100644
--- a/docs/superpowers/specs/2026-09-07-meet-design.md
+++ b/docs/superpowers/specs/2026-09-07-meet-design.md
@@ -1582,3 +1582,124 @@ so the client keeps both and takes whichever is shorter.
it, none of them proven within the window. The credential is fine;
the path is not — saying "expired" here would be a sentence about the
wrong thing.
+
+### 15.15 §4.3, §4.4, §5.4, §6, §10 — the chat and share wire, the share tile, and the panel's adoptable contract
+
+**Decision.** `POST /m/{sqid}/signal {"peer","type":"chat","payload":
+{"text":"…"}}` accepts a `text` of 1..2048 bytes of valid UTF-8;
+`{"type":"share","payload":{"on":true|false}}` carries nothing else.
+Either type is refused `400 {"error":"bad-request"}` for any other
+shape — a payload with an extra key, a `text` that is missing,
+non-string or empty, an `on` that is not a bool, or a `to` on either
+type (chat and share are never targeted; §4.3's fan-out has nowhere to
+route one) — and `413 {"error":"too-large"}` once the raw payload — the
+bytes on the wire, before decoding — exceeds 2048 + 32 bytes. The +32
+margin is the JSON structure the text sits inside (`{"text":"…"}`, the
+field name and its quoting), not a second allowance for the text
+itself; §4.4's 2 KB limit on the text is unchanged. The check runs, and
+`utf8.Valid` judges validity, against those raw bytes before any JSON
+decoding: a duplicated `"text"` key or a run of `\uXXXX` escapes both
+change the raw byte count without changing what a decoded field
+reports, and it is the raw bytes `Fanout` forwards to every other
+admitted peer, unread past this check.
+
+**The downstream envelope.** The hub's `Fanout` rebuilds the message it
+delivers from the sender's own participation — `{type, from, name,
+payload}`, where `from` and `name` are the sender's own peer id and
+display name, never anything the caller's request supplied — carrying
+no `to`, `seq`, `gen`, `reason`, `roster`, `boot` or any other field a
+targeted or server message might carry, however the request tried to
+set them. This is `Relay`'s own authority check (§15.12, ruling 14)
+reused for a fan-out instead of a single target: an unadmitted or
+foreign-session peer is refused before anything is sent, and a member
+whose standing has gone is ended and refused, exactly as `/signal`'s
+targeted types already are.
+
+**Recipients.** Every other admitted peer in the room, and never the
+sender: the sending tab's own panel echoes the line locally, on the
+acceptance the fan-out reports back, so echoing it again over the wire
+would show it twice. A waiting guest receives neither `chat` nor
+`share`, matching every other admitted-only message in §4.3's table;
+its own attempt to send either gets the same 404 an unknown peer id
+gets, never a 403 (§2.5). Nothing of a chat is written to any
+recipient's replay ring, and nothing is stored: §6's "not stored, not
+replayed" is enforced inside `Fanout` itself, which delivers to an
+attached subscriber or not at all — a detached, resumable peer's later
+`Resume` carries no chat it missed while detached, the same silence a
+peer who joined after it was sent already has.
+
+**The panel's mount API, as estilo's adoptable contract.** The chat
+panel is one template partial (`templates/partials/chat.html`) and one
+script (`static/chat.js`), following §10's rule that the panel is
+Meet's first contribution the suite can adopt rather than borrow. The
+contract a host must meet is `window.Chat.mount(root, {name, send}) →
+{append, setEnabled, clear}`: `send(text)` receives trimmed, non-empty
+text of at most 2048 bytes and returns, resolves, or throws to, a
+boolean; the panel appends the sender's own line —
+`append({name, text, self: true})` — only when that answer is the
+literal value `true`, never on any other truthy result, so a `Response`
+object or a non-boolean truthy answer reads as a refusal rather than an
+echo. `append` renders an arriving `{from, name, text, at, self}` with
+`textContent` only; `setEnabled` is the host's own gate, moved on
+admission and on every terminal or leave; `clear` empties the log,
+which is the whole of chat's history once the tab is gone (§6). Nothing
+executable in the two files names a room, a peer id, or Meet by
+anything but a code comment.
+
+**Screen share as its own transceiver.** §5.4's `getDisplayMedia` track
+is carried on one `sendonly` transceiver per connection
+(`addTransceiver(track, {direction: 'sendonly', streams:
+[share.stream]})`), added to every connection already open when
+sharing starts and to every connection built while it continues; the
+camera's own transceivers are untouched. A peer that receives a second
+video track for another peer draws it as a second tile, keyed
+`<peer>#share` and captioned from that peer's roster name and the
+`share` message alone — never a track label, which never crosses the
+wire. The tile is removed on whichever comes first: the received
+track's own `ended` (fired by the sender's `transceiver.stop()`, or by
+the browser's own "stop sharing" control) or a `share {on: false}`
+fan-out; the two are independent because a stopped transceiver does
+not reliably fire `ended` at the far end on every browser. The camera
+tile and its frame count are never touched by a share starting,
+stopping, or being dropped.
+
+### 15.16 §5.1 — the polite side yields before it offers
+
+**Decision.** Before making an offer, the polite side of a pair
+(§5.1's own tie-break, the lower peer id) waits 300 ms
+(`POLITE_YIELD`), and only once per negotiation: if a remote offer
+arrives during the wait, it is answered from a connection that is
+still `stable` and no offer of this side's own goes out; if none
+arrives, the wait ends and the offer goes out as it always did. A side
+that has already given way once does not give way again for the same
+negotiation — the flag guarding it is cleared only when this side's own
+offer actually goes out — so two peers renegotiating in turn cannot
+leave one of them permanently a beat behind the other.
+
+**Why.** Two peers who each add a screen-share transceiver to the same
+connection in the same moment collide, and the polite side's
+`setRemoteDescription` rolls its own offer back to accept the impolite
+side's. Chromium answers a rollback of an offer that added an m-line by
+tearing down and rebuilding every send and receive stream on that
+connection, not only the one the new m-line belongs to — so the
+camera's own sender comes back on the same SSRC having encoded
+nothing, and never encodes another frame, while its m-line goes on
+reporting `sendrecv/sendrecv` with a live track and the far side's
+matching receiver goes `muted`. Nothing in `room.js` ever touches the
+camera; the loss is entirely a side effect of the browser's own
+rollback. It reproduced in three of five runs of the browser gate's
+`mutualshare` scenario, with both screens perfect and both faces
+stopped, and was invisible in every other scenario because none of
+them checks the camera again after the call's first few seconds. The
+fix does not repair a camera that has already been rebuilt — there is
+no detection or repair path — it prevents the rollback from happening
+at all: an offer answered from `stable` needs no rollback.
+
+**The residual.** 300 ms covers the browser gate's own loopback
+signalling by two orders of magnitude and a real network hop by a
+comfortable margin, but it is a duration, not a guarantee: a pair whose
+signalling round trip exceeds it will still collide, and on that pair
+the rollback still costs the polite side its camera. If a deployment
+reports a camera stopping after two people share at once, this number
+is the first thing to check, and whether Chromium has fixed the
+underlying rollback behaviour is the second.