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
2 files changed,
+410
−0
docs/superpowers/reviews/2026-09-10-slice-4-record.md+289 −0docs/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. |