# Meet slice 4 — the record

Branch `slice/4-chat`, `3420396..HEAD` (this commit; started on main
`3420396`). §6 below is the Codex fix wave, which landed after the six
tasks and moved the head six times more — the range stated here is the
whole branch, record included, rather than the six tasks alone.
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. **Amended by
  the fix wave (§6):** the raw cap and `utf8.Valid` still run first and
  still judge the incoming bytes, but those bytes are no longer what
  `Fanout` forwards — the payload is re-encoded from the value that was
  validated, and §4.4's 2 KB is now counted on the escaped text, which
  is what the wire actually carries and what the panel can count too.
- **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.
  **Amended by the fix wave (§6):** "once per negotiation" was really
  once per connection until an offer of this side's own went out, which
  an earlier glare could spend for good. It is now a generation the
  yield checks on waking, one attempt in flight at a time, and an
  allowance of two handed back by any exchange this side answers — and
  a camera that was rebuilt anyway is now detected and repaired.
- **`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/calls/migrations
internal/calls/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

The tallies below are the six tasks'. The fix wave's own are in §6 and
in `codex-wave-report.md`: 194 node tests; 31 browser scenarios, 30
passing and `laterolls` skipped, run twice clean on the final head at
457 s and 455 s with no scenario lost to the environment stall on
either; and a `make browser` timeout raised to 20 minutes, which the
thirty-first scenario and four more media windows made necessary.

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

---

## 6. Codex round 1, and the fix wave

`codex exec` was launched against `8797ea9` while §2's last task was
being written, and the whole-branch review of `26c022b` ran alongside
it. Between them they found eleven things; six were Codex's (S4-1 to
S4-6), five were the review's (W4-1, W4-3 to W4-7). All eleven are
closed on this branch, in six commits, each with its new tests seen
failing first.

| Finding | Disposition | Commit | Test |
|---|---|---|---|
| **S4-1 (P1)** — `encoding/json` matches object keys to struct fields case-insensitively, and `DisallowUnknownFields` does not change that: `{"text":"<2049 a>","Text":"x"}` is 2071 raw bytes, decodes to the *last* match — a one-byte text — and was forwarded raw, so every recipient read the lowercase key and got 2049 bytes through a 2048-byte cap | Fixed. Keys are judged byte-exactly by `exactKey`, a token walk that insists on one member spelled exactly and appearing exactly once — deliberately not a `map[string]json.RawMessage`, which collapses a duplicate and cannot tell `{"text":"a","text":"b"}` from `{"text":"b"}` | `4bbadf3` | `TestChatKeysAreExactCaseSensitiveAndUnique`, ten cases; six of them returned 200 before the fix |
| **S4-2 (P1)** — `writeEvent`'s `json.Marshal` HTML-escapes `<`, `>` and `&` *inside* a `json.RawMessage`, so a validated 2059-byte payload of literal `<` left the process at 12,381 bytes — the invariant `validateFanout`'s whole doc comment rests on, broken on the way out | Fixed at both ends: `validateFanout` returns the payload re-encoded from the value it validated (`SetEscapeHTML(false)`), and `writeEvent` uses an encoder with the same setting. SSE data is not HTML, and the property the escaping never bought — that a payload cannot end its own frame — comes from JSON's own `\n` escape | `4bbadf3` | `TestChatDoesNotExpandOnTheWire`, `TestChatDeliversTheCanonicalValidatedPayload`, `TestChatCannotForgeASecondSSEFrame` |
| **S4-3 (P2)** — the textarea stays editable during a send, so `input.value = ''` on acceptance erased a draft typed while the earlier message was in flight | Fixed with a draft revision, bumped by every edit, captured by the submit, checked on acceptance. Clearing the box in code is not an edit, so an untouched box still empties | `a3a722f` | three tests in `chat.test.mjs`; the first failed `'' !== 'second draft'` |
| **S4-4 (P2)** — a pending polite yield survived the negotiation it belonged to (a state check cannot tell uninterrupted stability from an exchange that came and went), and `negotiate` did not stop a second invocation while the first slept | Fixed with three per-connection parts: a negotiation generation the yield captures and re-checks, one attempt in flight at a time, and `yields` — a counter in place of the boolean, handed back one at a time by `answeredExchange`. That last also closes the Task 4 re-review's item 1 and **W4-4**: the allowance is no longer a budget one earlier glare can spend for good | `41deb52` | five tests in `room-lane.test.mjs`, three of them mutation-verified against the shipped code |
| **S4-5 (P2)** — no recovery for a camera whose encoder was already rebuilt; the trigger survives POLITE_YIELD through a slow round trip, a 429 retry or a slow offer | Fixed. `pollFrames` runs in every browser now, not only where rVFC is missing, and reads outbound-rtp too: two polls with no new `framesEncoded` on a sending m-line with a live track is `replaceTrack(null)`/`replaceTrack(track)`; two more is `requestReset`; after that it stops asking. Closes the re-review's item 2 | `41deb52` | eight tests in `room-lane.test.mjs` over a fake stats sequence, mutation-verified |
| **S4-6 (P2)** — `mutualshare`'s later rounds read a counter `dropShare` deliberately preserves, so they could pass on round one's frames; every camera check compared against a pre-gesture snapshot, which frames delivered before a rollback stall satisfy | Fixed with `frameGrowth`: read the counter, *then* wait for it to rise. Every screen and camera assertion in `share` and `mutualshare` is rebuilt on it, after each transition settles and again through the observation window | `d8316e8` | the gate itself — and it earned its keep immediately (below) |
| **W4-1 (MEDIUM)** — the panel capped 2048 UTF-8 bytes of the text, the server 2080 raw JSON bytes of the payload; between them sat a draft the panel called legal and the server refused, needing only 23 quotes or newlines. **W4-2** falls with it: the "413: show beside the composer, never retry" line in `task-4-handoffs.md` was unimplementable through a boolean `send`, and is now unreachable | Fixed. One rule on both sides: the JSON-escaped length of the text, ≤ 2048 bytes — exactly what the canonical payload puts on the wire. The raw cap stays as the outer guard | `6f1be79` | `TestChatCapCountsTheEscapedText` (a quote, a newline and a backslash at and past the boundary) and `chat.test.mjs`'s counter test |
| **W4-3 (MEDIUM)** — `POLITE_YIELD` set to 0 left all 164 node tests green: the suite injected 250 of its own | Fixed. Every constant these tests assert against is read out of the source by `pin()`, and one test asserts the shipped values themselves — §15.16's 300 ms among them | `41deb52` | `the numbers these tests assert against are the ones room.js ships` |
| **W4-5 (LOW)** — the constant's comment claimed "the yield ends early because the remote offer has arrived"; the sleep is unconditional and the check is state-based | Fixed: the comment says what the code does | `41deb52` | — |
| **W4-6 (LOW)** — this record named `3420396..8797ea9`, one commit short of its own | Fixed at the top of this file | this commit | — |
| **W4-7 (LOW)** — §13 attached "no `chat` while waiting" to the `guest` scenario; it lives in `chat`, with a positive control | Fixed in the spec's own §13 sentence, which now also names the control | this commit | — |

**The gate found a defect the node tests could not.** The first run of
S4-6's fresh-baseline checks failed `mutualshare` round 1: the polite
side's screen announced, captioned, and never arriving — an unassociated
`sendonly` transceiver three rounds later and one inbound stream where
there should have been two. S4-4's serializer had done it. The polite
side adds its screen, sleeps in its yield, and answers the other side's
offer while asleep; that exchange fires `negotiationneeded` for the
polite side's *own* screen, which the serializer dropped because an
attempt was in flight, and then the attempt woke, saw the generation
had moved, and abandoned its offer. Nobody was left to offer the
screen. Fixed in `f982bdf`: an ask that arrives mid-attempt is
remembered on the connection and an attempt that abandons its offer
goes round again for it, bounded by `NEGOTIATE_ROUNDS`. Two node tests,
the first mutation-verified. Worth recording as a shape, not just an
incident: **the counter-based checks the old gate could not make are
what made the client defect visible at all**, which is the same lesson
`mutualshare` itself taught in Task 5.

**And then it found a second one, in the counter itself** (`3ecebb6`,
`2eadfd2`). `laterolls` reported a camera that had stopped arriving on
a tile that was playing perfectly — readyState 4, not paused, its clock
advancing, 453 frames decoded and rising. What had stopped was
`watchFrames`. Setting `srcObject` resets a media element and discards
its pending `requestVideoFrameCallback`, and the chain was keyed on a
`WeakSet` of elements, so a tile handed new media was refused a new
chain and its count stood still for the rest of the call — which every
frames assertion in the gate, and `state().frames` itself, reads as
dead media. Keying on the stream was not enough either, and the gate
said so on the next run: Chromium hands a rebuilt camera back inside
the *same* MediaStream on a new track. It is keyed on tile, stream and
track now, with a backstop for whatever else can drop a callback — a
chain that has counted nothing for two seconds while the element's own
clock moved on is re-armed, and `pollFrames` asks once a second, so a
lost callback costs a second rather than a call. Eight tests, two
mutation-verified. **The counter the whole browser gate stands on could
die quietly, and nothing but a real browser was ever going to say so.**

**And then a third, which was not the counter at all** (`fee4eb4`).
With the counter repaired, `laterolls` still failed on the same tile —
and this time the count was telling the truth. The polite side's tile
for the other camera sat at `currentTime` 0.1 for the rest of the
scenario, `readyState` 4 and not paused, on a connection that was
stable, connected, and decoding four hundred inbound frames nobody
could see. The element was rendering a track that had ended. This is
the *receiving* half of the same rollback damage §15.16 describes:
Chromium rebuilds receive streams too, so the far side's camera arrives
again as a new track — sometimes with no stream of its own, sometimes
inside the very stream that still holds the old, ended one — and
`onRemoteTrack` added the new one and left the old one in place, where
a media element goes on rendering it. `adoptTrack` takes an ended track
of its kind out before putting the new one in, and `tile()` now follows
the stream's video track as well as the stream object. Four tests,
mutation-verified.

Three defects, then, and the shape they share is worth naming: **every
one of them was invisible to the whole node suite and visible in the
first browser run that asked the right question.** The right question
in each case was "is this media moving *now*", asked from a baseline
taken now — which is exactly what S4-6 was about.

**A thirty-first scenario, `laterolls`.** §15.16's residual said a pair
whose signalling round trip exceeds 300 ms still collides, and that
nothing detected or repaired the camera afterwards. Half of that is now
fixed (S4-5) and the other half is now *forced* rather than waited for:
`laterolls` holds one context's own `offer` POSTs for four times the
yield, so the collision is late by construction, and then asserts the
camera is encoding again inside the repair budget. It asserts the
recovery and never the failure — a Chromium that no longer rebuilds the
streams passes it exactly as a repaired one does — and records whether
the stall happened as a line rather than a check.

**Said plainly: the delay is forced, the camera loss is not, and the
scenario ships skipped.** The delay is real — the scenario proves an
offer was held (`1 held`). The *encoder* loss §15.16 describes did not
reproduce: every run prints "the polite side's camera never stalled
through the late offer", so what the run exercises is the late
collision rather than the repair, and the repair itself is gated by the
node tests over a fake stats sequence. A scenario that could only pass
by breaking first would fail whenever the browser improves, which is
why it asserts recovery and never failure.

What it does reproduce, deterministically, is one step further in than
anything this wave fixed: after the late collision the *receiving*
tile stops rendering. The connection is stable and connected, all four
transceivers are right, every track is live, both inbound video streams
decode (452 and 446 frames, unmuted, one PLI each), the far side's
outbound camera rises throughout, and the screen tile plays perfectly —
while the camera tile sits at `readyState` 4, not paused,
`currentTime` 1.5 s, for the thirty seconds the check waits. Media
arrives, the decoder decodes it, the element renders none of it. No
other scenario reproduces it, and `mutualshare` renegotiates the same
connection six times over with both cameras rendering throughout, which
puts it closer to the environment's own media stall (§5) than to
anything in `room.js`. **Destination: `laterolls` is in `skipped` with
the exact failure and the first two things to check** — whether the
receiving track for that m-line is a new track object that never
reached `onRemoteTrack`, and whether `startPlaying` needs to re-play an
element whose media was rebuilt under it — on the same terms
`mutualshare` was shipped skipped in Task 5.

**What the fix wave did not take on.** The Task 4 re-review's items 3
to 7 stand where §3 left them: reserving the screen m-line at
`createPC` (the fix that removes the trigger rather than racing it),
settling whether the trigger is "a rollback that added an m-line" or
"any rollback once media flows", filing the Chromium bug, widening the
ordinary negotiation scenarios to assert frames afterwards, and two
peers stopping at once. Item 3 is still the real fix and still wants
its own task; what changed is that the failure it prevents is now
detected and repaired when it happens, so it is an improvement rather
than a gap.

**One ordering worth writing down**, from the whole-branch review's
§3: `leave()` and `terminal()` both set `state.stopped` *before*
calling `stopSharing()`, so the `share {on:false}` those paths trigger
short-circuits in `broadcast` and no POST goes out after the leave.
That is the right order, it is not obvious from reading `stopSharing`
alone, and no test would notice if a later edit inverted it.

---

## 7. Codex round 2

Judged at `55f5681`. S4-1, S4-2 and S4-3 closed; S4-4, S4-5 and S4-6
half-closed, each with the half that was missing named; five new
findings. All of them are closed on this branch, and the receiving-side
failure §6 left standing is closed with them.

| Finding | Disposition | Commit | Test |
|---|---|---|---|
| **S4-9 (P2)** — `adoptTrack` pruned only *ended* tracks, so a camera rebuilt on the known camera mid with its predecessor still `live` left the stream holding [old, new] and the tile showing the old one | Fixed. Each role — camera, screen — keeps a stream of this file's own; `adoptTrack` makes the arriving track *the* track of its kind in it, whatever the predecessor's readyState; `renderTiles` names the track each tile is meant to show and `tile()` binds to that, never to an array position | `560c9d2` | Four, all seen failing against the shipped source first; the same-mid replacement reproduced Codex's `cameraTracks: [old, new]`, `shownTrack: old` exactly |
| **S4-8 (P2)** — exhausting `NEGOTIATE_ROUNDS` cleared `renegotiate` in the `finally`, discarding an outstanding negotiation (waits 4, offers 0) | Fixed. The bound limits how long one attempt goes round, not whether outstanding work matters: the ask stays on the connection and `keepNegotiation` gives it the connection's next return to `stable`, or a timer where nothing else will announce one. One retry at a time; a connection that goes away keeps none | `5546a8d` | Three, two mutation-verified; the first reproduces Codex's trace and asserts the ask survives it |
| **S4-7 (P2)** — Go escapes U+2028/U+2029 unconditionally, `JSON.stringify` leaves them literal, so 2045 a's and one U+2028 counted 2048 in the composer and 2051 at the server | Fixed. `chatBytes` escapes them the way Go does. The pattern is written as escapes because both characters are line terminators in JavaScript source — which is most of why the gap existed | `bb97a15` | Client test seen failing first (6 !== 3); five matching server cases, sent as themselves the way a paste arrives |
| **S4-10 (P3)** — a decreasing `framesEncoded` counter read as "encoding recovered" and replenished the repair allowance | Fixed. The baseline moves either way, because the old number means nothing after a restart; the budget is forgiven only by frames that actually arrived | `716e0f6` | Two; the first seen failing first, the second holds the other side |
| **S4-11 (P3)** — every case in the canonicalisation regression sent an already-canonical payload | Fixed. Two cases now send real JSON escapes and require the decoded letters on the wire — verified by putting the raw bytes back, where those two fail and nothing else does — and `share` has output coverage at last | `716e0f6` | Seven cases, two of them discriminating |
| **The S4-6 gap** — `frameGrowth` returns on the first increase, so its timeout is a deadline and not an observation | Fixed. `sustained` requires growth in two successive intervals, and `checkMedia` asks it of everything a side should be receiving at once. Applied after every settled transition in `share` and `mutualshare`, including after the simultaneous stops and after the asymmetric stop's own five-second wait, which every media check used to precede | `00029e8` | The scenarios themselves; the round-two suppression mutation stays the gate on the baseline half |

**§15.16 no longer runs three facts together.** It now records
separately: the historical encoder-loss observation from Task 4 (which
stands as evidence of what a rollback costs, and is not the diagnosis
of anything still failing), what the yield does about it (prevents the
rollback; a duration, not a guarantee), and what is repaired when it
happens anyway (two bounded watches, both ending in a reset). The
sentence saying there is no detection or repair, and the one promising
recovery three paragraphs later, are both gone.

### The tile that would not paint — what the identities showed

S4-9 was a real defect. It was not this one. With the tile bound to the
role's own track, `laterolls` failed in the same place, and the
identity reading added for the purpose — `meet.media()`, which compares
the objects inside the page because identity does not survive an
`evaluate()` — settled what the failure is not:

```
wanted   ce46729b-…aef7
bound    ce46729b-…aef7
element  ["ce46729b-…aef7:live"]
receiver mid 1, sendrecv, live, muted:false, isCamera:true
agrees   true            currentTime 1.452
```

Three answers, one object, on both sides. The element held exactly the
track the connection was receiving the camera on, live and unmuted, and
the decoder went on delivering frames it never painted. Everything
upstream of the element was right and the picture was stopped
downstream of it — which is the branch Codex named for exactly this
result.

So a second watch, on the same one-second `getStats` walk and in the
same shape as the encoder one: `framesDecoded` per tile, and three
polls of "the decoder is delivering, the track is playable, the clock
has not moved" is a rendering stall. The repair is the stream taken off
the element and given back, and `play()` asked for again; two of those,
then a reset, then it stops asking. A tile receiving nothing is idle
rather than stalled, which is what reading the decode count alongside
the clock is for.

Measured either side of it, from the scenario's own 500 ms series of
rendered-frame totals: `[25,26,26,26,26,26]` — stopped, and stopped for
the twenty seconds the check waited — before, and `[25,25,25,25,25,26]`
after: frozen for the three polls it takes to notice, then moving
again. **`laterolls` is out of `skipped` and passing, and the
deterministic camera failure §6 recorded as a deploy blocker is
closed.**

`laterolls` also proves its own premise now, which it did not: it
counts remote offers applied over this side's own outstanding offer —
the rollback, seen from outside — and fails if there was none to
recover from. One was seen on every run.

### Three red runs, and what each of them was right about

The render watch did not land clean, and the gate was right every time.

1. **`playfallback`, twice.** §5 offers a Play control when the
   browser's autoplay policy refuses a remote video, and it stopped
   appearing. Guarding the watch on `paused` was not enough: the tile
   binds more than once now — audio arrives, then the camera — and the
   second bind called `play()` again behind the refusal and hid a
   question nobody had answered. `shouldPlay` is the rule that came out
   of it, and it is a product rule rather than a watch rule: while the
   Play control is showing, the gesture is the person's to give.
2. **`laterolls`, on a stutter.** The tile painted one frame every few
   seconds — `[22,23,23,23,23,25]` across three seconds — and any
   advance at all counted as health, so the watch's budget reset for
   ever and the connection never earned the reset that would rebuild
   it. A second of video is a second of clock; a frame is thirty
   milliseconds. `RENDER_MOVED` is 0.25 s now, a clock that goes
   backwards is the watch's own repair landing rather than a stall to
   judge, and the replay hands the element a *new* `MediaStream` over
   the same tracks rather than the object it was already holding.

Measured on the run that had been failing: `[25,25,25,25,25,25]` —
stopped dead — and then 21 → 26 across two successive intervals once
the watch escalated. **The stall is the browser's; what is claimed here
is a bounded recovery from it, and nothing more.**

**The gate on the final head:** `make browser` twice, **31 of 31 both
times**, 484 s and 485 s, with `laterolls` in it and `skipped` empty —
the first time this branch's gate has carried no skipped scenario since
Task 5 put `mutualshare` in it. 206 node tests, `make ci`, `-race` and
`go vet -tags browser` green.

---

## 8. Codex round 3

Judged at `a4d1ec1`. S4-7, S4-9, S4-10 and S4-11 closed; S4-8 and S4-6
half-closed, each with the half that was missing named as a new
finding; three more beside them. All five are closed on this branch.

| Finding | Disposition | Commit | Test |
|---|---|---|---|
| **S4-12 (P2)** — the retry timer spent the ask it was meant to protect: it fired mid-exchange, `negotiate` cleared the flag, slept, woke to a connection still not stable and walked away (pending false, timers none, offers none) | Fixed. An attempt that settles nothing owes the ask back: `politeYield` answers with the reason rather than a bare no, a `busy` wake and an offer that never went out both hand `renegotiate` back, and the timer defers instead of consuming, bounded by `NEGOTIATE_DEFERS` | `ad13608` | Five, both halves mutation-verified; the harness runs the timer callback now, which is what the round-2 tests never did |
| **S4-13 (P2)** — the render repair handed the element a fresh stream and left the role writing to the old one, so a microphone arriving or being replaced afterwards went where nothing was showing: authoritative [video, new mic], element [video, old mic] | Fixed. `freshStream` puts the new object back where the role keeps it, which restores the rule the file has about tiles: the element holds the role's stream, and every track adopted later reaches it | `5a0be1e` | Three replay tests, mutation-verified against the element-only version |
| **S4-15 (P2)** — a screen replaced on its own mid ends afterwards, and its `ended` listener checked only the connection, so the dead screen took the live replacement's tile with it | Fixed. The share is dropped only if the ending track is still the one being shown | `5a0be1e` | One test, mutation-verified; the screen that *is* showing still ends its tile |
| **S4-14 (P2)** — `sustained` was two early-return waits: 0, 1, 2, 3 then frozen for ever passed with a twenty-second argument, and streams were walked one after another | Fixed. `observe` requires growth in every one of three windows of 1.5 s, `watchMedia` reads every stream on both pages in one pass, and each caller names the keys it expects rather than asking the page what it is showing | `85e0a61` | Ten node tests in a new `media.test.mjs`, Codex's burst-then-freeze reproduction among them |
| **S4-16 (P3)** — §15.16 called the yield unconditional, listed slow stuttering as exempt when the watch repairs it, and claimed a recovery resting on those windows | Fixed in the spec: the yield is conditional on its allowance, stall criteria and exemptions are two separate lists, and the recovery claim is now "bounded and timed", with what is still not established said plainly | this commit | — |

**What the new windows found on their first run**, which is the whole
argument for them: `laterolls` went red with `28->28->28->28` on one
camera. Two of that was mine — the scenario's own pre-check was "the
counter is above zero", which a counter left over from before the
collision answers by itself, and the settle check ran before the
recovery rather than after it, so a pair mid-rebuild read as a pair
that never settled. Fixed in the same commit: growth from a fresh
baseline for the *arrival* of recovery, then settling, then the
windows. It passes with the stall really happening first — rendered
frames `[25,25,25,25,25,25]`, recovery at 26 → 28, and
`273->296->320->351` through the windows.

### The reproduction, kept for an upstream report

This is the material for a Chromium bug, and it is written down here
because it is the thing this slice learned that nobody else has.

**Setup.** Two peers on one connection, both sharing a screen at the
same moment, with one side's own `offer` requests held back four times
`POLITE_YIELD` (1200 ms) so the remote offer lands after the yield and
the polite side rolls back. The gate's `laterolls` scenario does
exactly this and counts the rollback (`setRemoteDescription` of an
offer while in `have-local-offer`) rather than assuming it.

**What is observed on the *receiving* side afterwards**, from
`meet.media()`, which compares the objects inside the page because
identity does not survive an `evaluate()`:

```
cameraMid  1
wanted     ce46729b-…aef7      (the track onRemoteTrack recorded)
bound      ce46729b-…aef7      (the track the tile is bound to)
element    ["ce46729b-…aef7:live"]   (the tracks in video.srcObject)
receivers  mid 1, sendrecv, live, muted:false, isCamera:true
agrees     true
currentTime 1.452               (and it does not move again)
```

with `getStats` on the same connection reporting `framesDecoded` rising
throughout for that mid, and the far side's `framesEncoded` rising too.
So: the sender encodes, the receiver decodes, the element holds exactly
the receiver's own live, unmuted track — and paints nothing. Everything
upstream of the element is right and the picture is stopped downstream
of it.

**The workaround that has an effect**, and it is a workaround: taking
the element's `srcObject` away and giving it a *new* `MediaStream` over
the same tracks. Re-assigning the same object does not do it — that was
measured, and it bought a frame or two. Rendered-frame totals every
500 ms, before and after the watch that does this: `[25,26,26,26,26,26]`
and `[25,25,25,25,25,26]`; a later run `[25,25,25,25,25,25]` then
recovery.

**What is not established.** Why a new stream object unblocks the
element; whether the decoder pipeline is rebuilt or merely re-attached;
and whether the rollback is the cause or only the trigger. Nothing here
identifies the Chromium defect — it locates it, downstream of decode
and upstream of paint, with a reproduction that runs in a headless
Chromium in under a minute.

**Destination: file it upstream**, with this scenario and these
readings. Until it is understood, §15.16 claims a bounded, timed
recovery and nothing more.

**The gate on the final head:** `make browser` twice, **31 of 31 both
times**, 523 s and 522 s, nothing skipped, with the timed windows in
every media check. 232 node tests — the five files plus the new
`media.test.mjs` — and `make ci`, `-race` and `go vet -tags browser`
green.