oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Meet slice 4 — the record
2
3Branch `slice/4-chat`, `3420396..HEAD` (this commit; started on main
4`3420396`). §6 below is the Codex fix wave, which landed after the six
5tasks and moved the head six times more — the range stated here is the
6whole branch, record included, rather than the six tasks alone.
7Spec: `docs/superpowers/specs/2026-09-07-meet-design.md`, revision 9
8(§15.15 and §15.16 carry this slice's two wire/behaviour rulings).
9
10Slice 4 built chat and screen share: `hub.Fanout`, `/signal`'s `chat`
11and `share` dispatch, the chat panel as estilo's first adoptable
12component, `room.js`'s broadcast lane and screen-share transceiver, and
13the browser gate's `chat`, `share` and `mutualshare` scenarios — six
14tasks, each with its own review and fix rounds, kept in
15`.superpowers/sdd/2026-09-10-meet-slice-4-chat/progress.md` and the
16`task-N-report.md` files. Like slice 2's and slice 3's, that ledger is
17gitignored and does not survive the worktree; this file is its
18substance.
19
20---
21
22## 1. Rulings and decisions
23
24From the ledger's pre-flight scan and the tasks that followed it:
25
26- **The fan-out excludes the sender; the panel echoes locally on
27 acceptance.** **Ruling.** Cost if wrong: a duplicate line in the
28 sender's own panel. Written into spec §15.15.
29- **Chat above 2048 bytes is `413 too-large`; every other malformed
30 shape — an empty text, an extra key, a non-bool `on`, a `to` on
31 either type — is `400 bad-request`.** **Ruling.** Cost if wrong:
32 none observable beyond which sentence the client's status line
33 shows. Written into spec §15.15.
34- **`share` carries only `{on}`; the tile's label is the sender's name
35 from the roster and the envelope, never a track label.** One sharer
36 at a time is not enforced — §5.4 already says so. Written into spec
37 §15.15.
38- **The size and UTF-8 checks run on the raw wire bytes, capped at
39 2048 + 32, not on the decoded field.** Found the hard way: Task 2's
40 first pass measured `len(*p.Text)` after decoding, which a
41 duplicated `"text"` key (JSON keeps only the last) or a run of
42 `\uXXXX` escapes (roughly a sixth of its raw size once decoded) both
43 defeat while the *raw* bytes — exactly what `Fanout` forwards
44 unread — stay large. Fixed in Task 2's fix round: `len(raw) >
45 maxChatBytes+32` before decoding, `utf8.Valid(raw)` on the same raw
46 bytes rather than `utf8.ValidString` on the decoded string (which a
47 scratch test proved is always valid, since `encoding/json` repairs
48 an invalid byte with U+FFFD on the way into a Go string — checking
49 the decoded value would let a genuinely invalid byte through while
50 reporting the payload clean). Written into spec §15.15. **Amended by
51 the fix wave (§6):** the raw cap and `utf8.Valid` still run first and
52 still judge the incoming bytes, but those bytes are no longer what
53 `Fanout` forwards — the payload is re-encoded from the value that was
54 validated, and §4.4's 2 KB is now counted on the escaped text, which
55 is what the wire actually carries and what the panel can count too.
56- **The gate drives `getDisplayMedia` with
57 `--auto-accept-this-tab-capture` alone.** Settled by a throwaway
58 probe before a line of Task 5's scenario was written: the existing
59 `--use-fake-ui-for-media-stream` flags already accept display
60 capture in this Chromium over `http://127.0.0.1` (a `data:` URL is
61 not a secure context and fails for an unrelated reason), so the
62 brief's fallback flag,
63 `--auto-select-desktop-capture-source="Entire screen"`, was never
64 needed. Kept anyway, belt and braces, because it is the documented
65 flag for the job and costs nothing.
66- **The polite side of a negotiation waits 300 ms before offering,
67 once per negotiation (`POLITE_YIELD`).** Found in Task 4's second fix
68 round, after the prescribed fix (releasing an unassociated share
69 transceiver before a rollback) was measured twice and shown not to
70 touch the actual cause. Chromium rebuilds every send and receive
71 stream on a connection — not only the m-line a rollback removes —
72 when it rolls back an offer that added an m-line, which is exactly
73 what two peers sharing a screen at the same instant does to the
74 polite side; the camera's sender comes back on the same SSRC having
75 encoded nothing, ever. An offer answered from `stable` needs no
76 rollback, which is what the yield buys. Measured at each step: yield
77 on the share path alone, 1 failure in 3; yield on every negotiation,
78 1 failure in 4; bounded to once per negotiation, 0 in 6, then 0 in 8
79 more, then 30/30 twice in the full gate. Written into spec §15.16.
80 **Amended by the fix wave (§6):** "once per negotiation" was really
81 once per connection until an offer of this side's own went out, which
82 an earlier glare could spend for good. It is now a generation the
83 yield checks on waking, one attempt in flight at a time, and an
84 allowance of two handed back by any exchange this side answers — and
85 a camera that was rebuilt anyway is now detected and repaired.
86- **`regainShare` and `shareTxLost`, added in Task 4's first fix round
87 to put a screen back after a rollback, were both no-ops where they
88 stood — found and removed in the second round.** `regainShare` called
89 `addShareTo`, which the same round had just taught to refuse
90 anything but a stable connection, and `onOffer` at that point is
91 `have-remote-offer`; it could never add anything. `shareTxLost` asked
92 whether the transceiver was stopped or had lost the local track,
93 which a rollback does neither — it un-associates a transceiver
94 `addTransceiver` created, and the display track stays on its sender.
95 The test that "discriminated" for the first round only proved a
96 stub was *called* in the right order, never that calling it did
97 anything. Not written into the spec: an implementation dead end, not
98 a decision the spec has an opinion about — recorded here so the
99 false path is not walked twice.
100- **`classifyTrack`'s four-signal order** — the receiving transceiver's
101 own direction, then the camera's mid, then the camera's stream id,
102 then the peer's other-media stream id, and only then a tiebreaker —
103 is an implementation decision the spec has no opinion about (§5.4
104 says a peer's screen becomes a second tile; it does not say how a
105 mesh peer tells a screen from a camera on the wire). Found necessary
106 in Task 4's first fix round once the reviewer showed the blind spot
107 the first draft's report had wrongly called unreachable: a peer with
108 no camera (the audio-only rung of `startLocal`'s fallback ladder)
109 really can have its screen be the first video track its peers ever
110 see.
111- **The one hole the classifier still has is documented, not closed.**
112 A peer with no camera *and* no microphone has sent nothing before
113 its screen — no camera mid, no camera stream, no peer stream — and
114 if the receiving tab also has no camera, direction says nothing
115 either. Classification then rests entirely on the `share` message,
116 which §4.3 delivers or reports as failed. Decided as acceptable
117 rather than closed: see §3 below.
118- **`required` is dropped from the chat textarea rather than routing
119 Enter through `form.requestSubmit()`.** Task 3's fix round: this
120 form never reaches a server, so `required` buys exactly one thing —
121 the browser's own validation-bubble sentence on an empty click of
122 Send, worded and toned by the browser rather than this app's copy
123 list, and untranslatable by it. Both submit paths (Send, Enter) now
124 return silently on an empty or whitespace-only box, which agrees
125 with every other product's chat box and adds no string at all.
126
127---
128
129## 2. Per-task review dispositions
130
131| Task | Built | Review | Fix rounds | Final |
132|---|---|---|---|---|
133| 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. |
134| 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. |
135| 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. |
136| 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. |
137| 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. |
138| 6 — this record and spec §15.15/§15.16 | Documentation only | — | — | This commit. |
139
140---
141
142## 3. Carried items, and where they go
143
144- **The no-media sharer hole in the classifier is documented, not
145 closed.** A peer that joined with no camera and no microphone has
146 sent nothing before its screen — no camera mid, no camera stream, no
147 peer stream to compare against — and if the receiving tab also has
148 no camera, the transceiver-direction signal says nothing either.
149 Classification then rests entirely on the `share` message.
150 **Destination: accepted as a known limitation**, backstopped by
151 §4.3's own delivery guarantee on `share` — not a bug this slice owes
152 a fix for, and named in `classifyTrack`'s own comment so the next
153 reader finds it there first.
154- **`dropShare` and `shareCaption`, now pinned.** Task 5's first report
155 flagged both as untested by anything but the browser gate, since
156 that task cannot mutate `room.js` while a reviewer reads it
157 concurrently. `shareCaption` was lifted into `room-share.test.mjs` in
158 Task 5's fix round (the caption text, that it carries no ASCII
159 apostrophe, and that a name with a space goes in whole); `dropShare`
160 was pinned in Task 4's second fix round (clears the label, the media
161 and the mid; twice is the same as once; redraws the grid once, not
162 twice; a screen announced but never arrived is still dropped).
163 **Destination: done**, both closed on the branch.
164- **`diagnose()` reading inbound-rtp.** Carried from Task 5's first
165 report (mutation testing against the client was impossible, and its
166 `diagnose()` said nothing about the media itself) into Task 5's fix
167 round: per connection, `inbound` (ssrc, `framesReceived`,
168 `framesDecoded`, `framesDropped`, `pliCount`, `freezeCount`, the
169 track's own `muted`) and `displaySurface`, then `outbound` and
170 mid-plus-direction transceiver strings added while diagnosing
171 `mutualshare` itself. **Destination: done** — this reading is exactly
172 what let Task 4's second fix round read the root cause off two
173 connections' own stats rather than guess at it.
174- **The 300 ms polite-yield residual.** Task 4's second fix round
175 closed the mutual-share defect with a 300 ms wait, not a proof: a
176 pair whose signalling round trip exceeds 300 ms can still collide,
177 and on that pair the rollback still costs the polite side its
178 camera. Nothing detects or repairs a camera that has already been
179 rebuilt. **Destination: watched, not fixed further this slice** —
180 recorded in spec §15.16 as the first thing to check if a real
181 deployment ever reports a camera stopping after two people share at
182 once, and the second thing to check is whether Chromium has fixed
183 the rollback itself.
184- **The `make browser` flake rate and its logs.** Across this slice's
185 runs: Task 4's first pass, 26/27 then 27/27; Task 4's first fix
186 round, 28/29 then 29/29; Task 5's first pass, three of four runs each
187 lost one *different* scenario (`senddeadline`, `share`, `icefail`) to
188 the stall before a clean 29/29; Task 5's fix round exposed the real
189 `mutualshare` defect (not a flake — 3 failures in 5, always the same
190 camera signature) and shipped it skipped, then a clean 29/1-skip;
191 Task 4's second fix round ran clean, 30/30 twice, 447 s each. Roughly
192 half of this slice's `make browser` runs lost exactly one scenario to
193 the environment's own media stall, and every stalled scenario passed
194 alone immediately afterward. **Destination: unchanged from slice 3**
195 — a red `make browser` run is worth a second run before it is worth
196 debugging. Every run's log this slice was kept under `$TMPDIR`
197 (`task5-browser-run3.log` … `task5-fix1-three-scenarios.log`,
198 `task5-mutualshare-try*.log`), not committed; §5 below gives the
199 stall's signature.
200- **What the Task 4 re-review named, landing clean itself.** The
201 re-review of fix round 2 (`8797ea9`) returned **Clean** — nothing on
202 the branch itself needs another round — and carried eight items to
203 the fix wave rather than to this branch, each to be measured against
204 `mutualshare` when it is picked up: (1) `entry.yielded` is cleared
205 only by this side's own successful offer, so a lost yield whose
206 remote exchange happens to satisfy the same need leaves the polite
207 side unprotected next time — clear it on any stable return, not only
208 this side's own, bounded by a counter so it still cannot become a
209 habit; (2) a detect-and-repair path for a camera already rebuilt —
210 outbound-rtp `framesEncoded` static on the camera's mid while the
211 track is live and the m-line is sending → `replaceTrack(null)` then
212 `replaceTrack(track)`, followed by `requestReset`; (3) evaluate
213 reserving the screen's m-line at `createPC` itself (an inactive
214 transceiver from the start; starting a share becomes a
215 `replaceTrack` plus a direction change) so that starting a share
216 never renegotiates a new m-line into existence at all; (4) settle
217 whether the true trigger is "a rollback that added an m-line"
218 specifically or "any rollback once media is already flowing" — two
219 simultaneous ICE restarts with no share involved is the case that
220 would tell them apart; (5) file the defect against Chromium itself,
221 with the stats trace this slice captured; (6) widen the ordinary
222 negotiation scenarios to assert frames keep moving afterwards, not
223 only at connection; (7) two peers *stopping* a share at the same
224 instant is exactly as ungated as two starting one, and untested;
225 (8) pin `POLITE_YIELD` from the shipped source rather than retype
226 it, and correct one claim already written down: the report's "the
227 yield ends early" reads as a race that resolves on its own, when the
228 code is an unconditional sleep gated by a state check taken after it
229 — accurate for what ships, but the sentence describing it overstates
230 how the two interleave. **Destination: the fix wave**, alongside a
231 reviewer's own draft of a further spec note for §5.1 on the yield,
232 which this record does not reproduce — read it in the ledger
233 (`progress.md`) alongside the whole-branch review's own findings and
234 its handoffs for the Codex round and for slices 5 and 6.
235- **Codex round 1, launched on this branch after this ledger entry.**
236 `codex exec` was launched against `8797ea9` as this record was being
237 written; nothing from it had landed by the time this commit was
238 made. **Destination:** its own entry in this file (or the next
239 slice's), the way slice 3's Codex rounds were appended to that
240 slice's own record after the fact — not fabricated here ahead of the
241 result.
242
243---
244
245## 4. Deploying this slice
246
247**No schema change.** Nothing in this slice touches `models.go`,
248`migrations.go` or `migrations/` — chat and share are both delivered
249messages, never rows (`git diff 3420396..8797ea9 -- internal/calls/migrations
250internal/calls/models.go` is empty). Deploying this slice is an
251ordinary rolling deploy with no migration step to sequence around it.
252
253**Every deploy still drops every live call**, exactly as slice 2's
254record already states (spec §15.11 — the hub is per process and dies
255with it) and slice 3's did not change: this slice adds nothing to the
256hub's own durability. A chat message in flight at deploy time is not
257queued or recovered; it is simply not delivered, the same as an offer
258would be. Members rejoin automatically; guests go back to waiting.
259
260**Chat is not stored**, on this instance or any other: §6's "not
261stored, not replayed" holds all the way down to `Fanout`, which
262delivers to an attached subscriber or not at all. There is nothing in
263a database backup, a log line, or a crash dump that a deploy could
264lose that chat did not already lack the moment it was sent — the
265property a deploy dropping a call would otherwise threaten does not
266apply here, because there was never anything held to drop.
267
268No test on this branch touches a real deployment window; the gates
269below prove the wire and the browser behaviour against fixtures and a
270local pair of Chromium contexts, the same shape slice 2 and slice 3
271used.
272
273---
274
275## 5. The gates, and how to run them
276
277The tallies below are the six tasks'. The fix wave's own are in §6 and
278in `codex-wave-report.md`: 194 node tests; 31 browser scenarios, 30
279passing and `laterolls` skipped, run twice clean on the final head at
280457 s and 455 s with no scenario lost to the environment stall on
281either; and a `make browser` timeout raised to 20 minutes, which the
282thirty-first scenario and four more media windows made necessary.
283
284| Gate | Command | Notes |
285|---|---|---|
286| The one gate | `CGO_ENABLED=0 make ci` | vet, gofmt, tests, migration check — unchanged from slice 3. |
287| Race | `CGO_ENABLED=1 go test -race ./internal/...` | Run through this slice's fix rounds; green throughout. |
288| 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. |
289| 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. |
290
291**The one-scenario media stall, and its signature.** Roughly half of
292this slice's `make browser` runs lost exactly one scenario — a
293different one each time, never the same twice in a row — to the
294environment's own known symptom, unrelated to anything this slice
295built (the Makefile's own comment above `browser:` names it, and
296slice 3's record already caught it against the ICE scenarios). The
297signature, quoted from one losing run: `connection: "connected"`,
298`ice: "connected"`, both tracks reporting `"live"`, `transceivers:
299["sendrecv:audio","sendrecv:video"]` — and `frames: {}`. The transport
300is up, negotiation is done, and no pixels ever move; every stalled
301scenario this slice hit passed alone, immediately, on a second try.
302This is not the stale-roster defect slice 3 fixed (whose signature was
303`peers: []`) and it is not the `mutualshare` defect this slice fixed
304(whose signature was two live video streams with one encoding zero
305frames) — it is the environment stall both of those records already
306name, reached by a third scenario each time.
307
308`make browser` is still deliberately outside `make ci`, for the reason
309slice 2's record already gives. As with slice 3's residual flake, a red
310run here is worth a second run before it is worth debugging.
311
312---
313
314## 6. Codex round 1, and the fix wave
315
316`codex exec` was launched against `8797ea9` while §2's last task was
317being written, and the whole-branch review of `26c022b` ran alongside
318it. Between them they found eleven things; six were Codex's (S4-1 to
319S4-6), five were the review's (W4-1, W4-3 to W4-7). All eleven are
320closed on this branch, in six commits, each with its new tests seen
321failing first.
322
323| Finding | Disposition | Commit | Test |
324|---|---|---|---|
325| **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 |
326| **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` |
327| **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'` |
328| **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 |
329| **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 |
330| **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) |
331| **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 |
332| **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` |
333| **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` | — |
334| **W4-6 (LOW)** — this record named `3420396..8797ea9`, one commit short of its own | Fixed at the top of this file | this commit | — |
335| **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 | — |
336
337**The gate found a defect the node tests could not.** The first run of
338S4-6's fresh-baseline checks failed `mutualshare` round 1: the polite
339side's screen announced, captioned, and never arriving — an unassociated
340`sendonly` transceiver three rounds later and one inbound stream where
341there should have been two. S4-4's serializer had done it. The polite
342side adds its screen, sleeps in its yield, and answers the other side's
343offer while asleep; that exchange fires `negotiationneeded` for the
344polite side's *own* screen, which the serializer dropped because an
345attempt was in flight, and then the attempt woke, saw the generation
346had moved, and abandoned its offer. Nobody was left to offer the
347screen. Fixed in `f982bdf`: an ask that arrives mid-attempt is
348remembered on the connection and an attempt that abandons its offer
349goes round again for it, bounded by `NEGOTIATE_ROUNDS`. Two node tests,
350the first mutation-verified. Worth recording as a shape, not just an
351incident: **the counter-based checks the old gate could not make are
352what made the client defect visible at all**, which is the same lesson
353`mutualshare` itself taught in Task 5.
354
355**And then it found a second one, in the counter itself** (`3ecebb6`,
356`2eadfd2`). `laterolls` reported a camera that had stopped arriving on
357a tile that was playing perfectly — readyState 4, not paused, its clock
358advancing, 453 frames decoded and rising. What had stopped was
359`watchFrames`. Setting `srcObject` resets a media element and discards
360its pending `requestVideoFrameCallback`, and the chain was keyed on a
361`WeakSet` of elements, so a tile handed new media was refused a new
362chain and its count stood still for the rest of the call — which every
363frames assertion in the gate, and `state().frames` itself, reads as
364dead media. Keying on the stream was not enough either, and the gate
365said so on the next run: Chromium hands a rebuilt camera back inside
366the *same* MediaStream on a new track. It is keyed on tile, stream and
367track now, with a backstop for whatever else can drop a callback — a
368chain that has counted nothing for two seconds while the element's own
369clock moved on is re-armed, and `pollFrames` asks once a second, so a
370lost callback costs a second rather than a call. Eight tests, two
371mutation-verified. **The counter the whole browser gate stands on could
372die quietly, and nothing but a real browser was ever going to say so.**
373
374**And then a third, which was not the counter at all** (`fee4eb4`).
375With the counter repaired, `laterolls` still failed on the same tile —
376and this time the count was telling the truth. The polite side's tile
377for the other camera sat at `currentTime` 0.1 for the rest of the
378scenario, `readyState` 4 and not paused, on a connection that was
379stable, connected, and decoding four hundred inbound frames nobody
380could see. The element was rendering a track that had ended. This is
381the *receiving* half of the same rollback damage §15.16 describes:
382Chromium rebuilds receive streams too, so the far side's camera arrives
383again as a new track — sometimes with no stream of its own, sometimes
384inside the very stream that still holds the old, ended one — and
385`onRemoteTrack` added the new one and left the old one in place, where
386a media element goes on rendering it. `adoptTrack` takes an ended track
387of its kind out before putting the new one in, and `tile()` now follows
388the stream's video track as well as the stream object. Four tests,
389mutation-verified.
390
391Three defects, then, and the shape they share is worth naming: **every
392one of them was invisible to the whole node suite and visible in the
393first browser run that asked the right question.** The right question
394in each case was "is this media moving *now*", asked from a baseline
395taken now — which is exactly what S4-6 was about.
396
397**A thirty-first scenario, `laterolls`.** §15.16's residual said a pair
398whose signalling round trip exceeds 300 ms still collides, and that
399nothing detected or repaired the camera afterwards. Half of that is now
400fixed (S4-5) and the other half is now *forced* rather than waited for:
401`laterolls` holds one context's own `offer` POSTs for four times the
402yield, so the collision is late by construction, and then asserts the
403camera is encoding again inside the repair budget. It asserts the
404recovery and never the failure — a Chromium that no longer rebuilds the
405streams passes it exactly as a repaired one does — and records whether
406the stall happened as a line rather than a check.
407
408**Said plainly: the delay is forced, the camera loss is not, and the
409scenario ships skipped.** The delay is real — the scenario proves an
410offer was held (`1 held`). The *encoder* loss §15.16 describes did not
411reproduce: every run prints "the polite side's camera never stalled
412through the late offer", so what the run exercises is the late
413collision rather than the repair, and the repair itself is gated by the
414node tests over a fake stats sequence. A scenario that could only pass
415by breaking first would fail whenever the browser improves, which is
416why it asserts recovery and never failure.
417
418What it does reproduce, deterministically, is one step further in than
419anything this wave fixed: after the late collision the *receiving*
420tile stops rendering. The connection is stable and connected, all four
421transceivers are right, every track is live, both inbound video streams
422decode (452 and 446 frames, unmuted, one PLI each), the far side's
423outbound camera rises throughout, and the screen tile plays perfectly —
424while the camera tile sits at `readyState` 4, not paused,
425`currentTime` 1.5 s, for the thirty seconds the check waits. Media
426arrives, the decoder decodes it, the element renders none of it. No
427other scenario reproduces it, and `mutualshare` renegotiates the same
428connection six times over with both cameras rendering throughout, which
429puts it closer to the environment's own media stall (§5) than to
430anything in `room.js`. **Destination: `laterolls` is in `skipped` with
431the exact failure and the first two things to check** — whether the
432receiving track for that m-line is a new track object that never
433reached `onRemoteTrack`, and whether `startPlaying` needs to re-play an
434element whose media was rebuilt under it — on the same terms
435`mutualshare` was shipped skipped in Task 5.
436
437**What the fix wave did not take on.** The Task 4 re-review's items 3
438to 7 stand where §3 left them: reserving the screen m-line at
439`createPC` (the fix that removes the trigger rather than racing it),
440settling whether the trigger is "a rollback that added an m-line" or
441"any rollback once media flows", filing the Chromium bug, widening the
442ordinary negotiation scenarios to assert frames afterwards, and two
443peers stopping at once. Item 3 is still the real fix and still wants
444its own task; what changed is that the failure it prevents is now
445detected and repaired when it happens, so it is an improvement rather
446than a gap.
447
448**One ordering worth writing down**, from the whole-branch review's
449§3: `leave()` and `terminal()` both set `state.stopped` *before*
450calling `stopSharing()`, so the `share {on:false}` those paths trigger
451short-circuits in `broadcast` and no POST goes out after the leave.
452That is the right order, it is not obvious from reading `stopSharing`
453alone, and no test would notice if a later edit inverted it.
454
455---
456
457## 7. Codex round 2
458
459Judged at `55f5681`. S4-1, S4-2 and S4-3 closed; S4-4, S4-5 and S4-6
460half-closed, each with the half that was missing named; five new
461findings. All of them are closed on this branch, and the receiving-side
462failure §6 left standing is closed with them.
463
464| Finding | Disposition | Commit | Test |
465|---|---|---|---|
466| **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 |
467| **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 |
468| **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 |
469| **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 |
470| **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 |
471| **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 |
472
473**§15.16 no longer runs three facts together.** It now records
474separately: the historical encoder-loss observation from Task 4 (which
475stands as evidence of what a rollback costs, and is not the diagnosis
476of anything still failing), what the yield does about it (prevents the
477rollback; a duration, not a guarantee), and what is repaired when it
478happens anyway (two bounded watches, both ending in a reset). The
479sentence saying there is no detection or repair, and the one promising
480recovery three paragraphs later, are both gone.
481
482### The tile that would not paint — what the identities showed
483
484S4-9 was a real defect. It was not this one. With the tile bound to the
485role's own track, `laterolls` failed in the same place, and the
486identity reading added for the purpose — `meet.media()`, which compares
487the objects inside the page because identity does not survive an
488`evaluate()` — settled what the failure is not:
489
490```
491wanted ce46729b-…aef7
492bound ce46729b-…aef7
493element ["ce46729b-…aef7:live"]
494receiver mid 1, sendrecv, live, muted:false, isCamera:true
495agrees true currentTime 1.452
496```
497
498Three answers, one object, on both sides. The element held exactly the
499track the connection was receiving the camera on, live and unmuted, and
500the decoder went on delivering frames it never painted. Everything
501upstream of the element was right and the picture was stopped
502downstream of it — which is the branch Codex named for exactly this
503result.
504
505So a second watch, on the same one-second `getStats` walk and in the
506same shape as the encoder one: `framesDecoded` per tile, and three
507polls of "the decoder is delivering, the track is playable, the clock
508has not moved" is a rendering stall. The repair is the stream taken off
509the element and given back, and `play()` asked for again; two of those,
510then a reset, then it stops asking. A tile receiving nothing is idle
511rather than stalled, which is what reading the decode count alongside
512the clock is for.
513
514Measured either side of it, from the scenario's own 500 ms series of
515rendered-frame totals: `[25,26,26,26,26,26]` — stopped, and stopped for
516the twenty seconds the check waited — before, and `[25,25,25,25,25,26]`
517after: frozen for the three polls it takes to notice, then moving
518again. **`laterolls` is out of `skipped` and passing, and the
519deterministic camera failure §6 recorded as a deploy blocker is
520closed.**
521
522`laterolls` also proves its own premise now, which it did not: it
523counts remote offers applied over this side's own outstanding offer —
524the rollback, seen from outside — and fails if there was none to
525recover from. One was seen on every run.
526
527### Three red runs, and what each of them was right about
528
529The render watch did not land clean, and the gate was right every time.
530
5311. **`playfallback`, twice.** §5 offers a Play control when the
532 browser's autoplay policy refuses a remote video, and it stopped
533 appearing. Guarding the watch on `paused` was not enough: the tile
534 binds more than once now — audio arrives, then the camera — and the
535 second bind called `play()` again behind the refusal and hid a
536 question nobody had answered. `shouldPlay` is the rule that came out
537 of it, and it is a product rule rather than a watch rule: while the
538 Play control is showing, the gesture is the person's to give.
5392. **`laterolls`, on a stutter.** The tile painted one frame every few
540 seconds — `[22,23,23,23,23,25]` across three seconds — and any
541 advance at all counted as health, so the watch's budget reset for
542 ever and the connection never earned the reset that would rebuild
543 it. A second of video is a second of clock; a frame is thirty
544 milliseconds. `RENDER_MOVED` is 0.25 s now, a clock that goes
545 backwards is the watch's own repair landing rather than a stall to
546 judge, and the replay hands the element a *new* `MediaStream` over
547 the same tracks rather than the object it was already holding.
548
549Measured on the run that had been failing: `[25,25,25,25,25,25]` —
550stopped dead — and then 21 → 26 across two successive intervals once
551the watch escalated. **The stall is the browser's; what is claimed here
552is a bounded recovery from it, and nothing more.**
553
554**The gate on the final head:** `make browser` twice, **31 of 31 both
555times**, 484 s and 485 s, with `laterolls` in it and `skipped` empty —
556the first time this branch's gate has carried no skipped scenario since
557Task 5 put `mutualshare` in it. 206 node tests, `make ci`, `-race` and
558`go vet -tags browser` green.
559
560---
561
562## 8. Codex round 3
563
564Judged at `a4d1ec1`. S4-7, S4-9, S4-10 and S4-11 closed; S4-8 and S4-6
565half-closed, each with the half that was missing named as a new
566finding; three more beside them. All five are closed on this branch.
567
568| Finding | Disposition | Commit | Test |
569|---|---|---|---|
570| **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 |
571| **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 |
572| **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 |
573| **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 |
574| **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 | — |
575
576**What the new windows found on their first run**, which is the whole
577argument for them: `laterolls` went red with `28->28->28->28` on one
578camera. Two of that was mine — the scenario's own pre-check was "the
579counter is above zero", which a counter left over from before the
580collision answers by itself, and the settle check ran before the
581recovery rather than after it, so a pair mid-rebuild read as a pair
582that never settled. Fixed in the same commit: growth from a fresh
583baseline for the *arrival* of recovery, then settling, then the
584windows. It passes with the stall really happening first — rendered
585frames `[25,25,25,25,25,25]`, recovery at 26 → 28, and
586`273->296->320->351` through the windows.
587
588### The reproduction, kept for an upstream report
589
590This is the material for a Chromium bug, and it is written down here
591because it is the thing this slice learned that nobody else has.
592
593**Setup.** Two peers on one connection, both sharing a screen at the
594same moment, with one side's own `offer` requests held back four times
595`POLITE_YIELD` (1200 ms) so the remote offer lands after the yield and
596the polite side rolls back. The gate's `laterolls` scenario does
597exactly this and counts the rollback (`setRemoteDescription` of an
598offer while in `have-local-offer`) rather than assuming it.
599
600**What is observed on the *receiving* side afterwards**, from
601`meet.media()`, which compares the objects inside the page because
602identity does not survive an `evaluate()`:
603
604```
605cameraMid 1
606wanted ce46729b-…aef7 (the track onRemoteTrack recorded)
607bound ce46729b-…aef7 (the track the tile is bound to)
608element ["ce46729b-…aef7:live"] (the tracks in video.srcObject)
609receivers mid 1, sendrecv, live, muted:false, isCamera:true
610agrees true
611currentTime 1.452 (and it does not move again)
612```
613
614with `getStats` on the same connection reporting `framesDecoded` rising
615throughout for that mid, and the far side's `framesEncoded` rising too.
616So: the sender encodes, the receiver decodes, the element holds exactly
617the receiver's own live, unmuted track — and paints nothing. Everything
618upstream of the element is right and the picture is stopped downstream
619of it.
620
621**The workaround that has an effect**, and it is a workaround: taking
622the element's `srcObject` away and giving it a *new* `MediaStream` over
623the same tracks. Re-assigning the same object does not do it — that was
624measured, and it bought a frame or two. Rendered-frame totals every
625500 ms, before and after the watch that does this: `[25,26,26,26,26,26]`
626and `[25,25,25,25,25,26]`; a later run `[25,25,25,25,25,25]` then
627recovery.
628
629**What is not established.** Why a new stream object unblocks the
630element; whether the decoder pipeline is rebuilt or merely re-attached;
631and whether the rollback is the cause or only the trigger. Nothing here
632identifies the Chromium defect — it locates it, downstream of decode
633and upstream of paint, with a reproduction that runs in a headless
634Chromium in under a minute.
635
636**Destination: file it upstream**, with this scenario and these
637readings. Until it is understood, §15.16 claims a bounded, timed
638recovery and nothing more.
639
640**The gate on the final head:** `make browser` twice, **31 of 31 both
641times**, 523 s and 522 s, nothing skipped, with the timed windows in
642every media check. 232 node tests — the five files plus the new
643`media.test.mjs` — and `make ci`, `-race` and `go vet -tags browser`
644green.