oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

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 POLITEYIELD 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) — `POLITEYIELD 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.