Post-merge review of #358: bound the shape of an echoed refusal, refuse a second meeting out loud, make the zero-failure row fail - #362
Open
lilseyi wants to merge 4 commits into
Conversation
`describeEventType` was added to stop `foldLog` echoing an unrecognised `event.type` back unbounded, and the reason it exists is that the sentence it builds is written to a customer's log file by `apps/desktop` -- as the tail of one line: `meeting_write_refused session=... kind=... status=... code=...: <sentence>`. A length bound does not see the thing that makes that dangerous. A newline forges a second line in that file in one character, and an escape sequence rewrites the line already printed in four; forty characters is no defence against either. This is the case `assertSafeEtag` in `src/store/index.js` already handles one folder over, and for the same reason: a value about to be interpolated into a line with structure gets its charset checked, not its length. So the shape is checked first. An event type in this contract is an identifier, so one that is not is described (`non-identifier`) rather than quoted, and one that is keeps being named and truncated exactly as before -- naming it is the one thing a client author can act on. The same commit pins the other half of the residue this file's sibling change named and did not close: `ingest.js` refuses transcript, notes and a re-open to a `complete` or an `empty` session by naming those two states literally, where the property that matters is *terminal*. The phone's `acceptsTranscript` was derived from `MEETING_TRANSITIONS` for exactly that reason; this side cannot derive as cheaply, because each refusal carries its own sentence per state -- so the coupling is checked instead of assumed, and the day the table grows a third terminal state the suite says which three functions have to learn about it. Sabotage, measured: dropping the shape check and keeping the length bound -- 2; adding a third terminal state to `MEETING_TRANSITIONS` -- 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Ht7GUWMd9t4xDNED9NDEz
Both phone recorders returned silently from `start()` while already recording. That is right for the *same* meeting twice -- a double press is one start -- and it was quietly wrong for a different one, because the recorder went on minting chunk ids for the meeting it opened with. The press is real. `MeetingsListScreen` hides its record button while a meeting is live; the console's own Record key (`ConsoleBottomBar`) is drawn whether or not one is, so `controller.start()` can be called for a second meeting while the first is still recording. Before phone chunk ids named their meeting, that press was silent contamination: the first meeting's audio landed in the second meeting's transcript, at the first meeting's offsets, with nothing in the id to say so. Now that they name it, `controller.apply` refuses every one of those segments -- correctly -- and the same press produces a second meeting that records nothing at all and says nothing about it. Neither is an outcome to leave somebody in. The recorder is the only place that knows both meetings' names, so it refuses: a `start()` for a meeting other than the one it is recording throws `ALREADY_RECORDING`, which `controller.start` handles the way it already handles a denied microphone -- the session stays, the notepad keeps working, and the sentence goes on the live screen beside it. A `start()` for the meeting already running is still one start. The id is read before the state now, in both files, because what "already recording" means depends on it. Sabotage, measured: returning silently for a different meeting again -- 1 in `meetingsCapture.test.ts` and 1 in `meetingsCaptureWeb.test.ts`; throwing for the same meeting as well -- 1 in each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Ht7GUWMd9t4xDNED9NDEz
`acceptsTranscript` was derived from `MEETING_TRANSITIONS` rather than naming `complete` and `empty` by hand, and the test written with it drives every state the table names -- but both sides of that test read the same table, and the derived answer and the old denylist agree on today's data. Reverting the source left it green, which the change reported honestly as a zero-failure row. A guard nobody has checked is not a guard, so this checks the derivation rather than its current output: a third terminal state is added to a copy of the table through the module registry, and the answer for it has to come out of the table rather than out of two names somebody typed. A denylist has never heard of that state, so reverting the source now fails here. Sabotage, measured: `acceptsTranscript` back to `state !== "complete" && state !== "empty"` -- 1, where it was 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Ht7GUWMd9t4xDNED9NDEz
Three documentation corrections, from a post-merge adversarial review of the change that gave phone chunk ids a meeting to name. `MAX_CHUNK_ID_LENGTH`'s comment still described the ids the recorders mint as `<Date.now()>-<index>`, which is exactly what that change stopped being true, and pointed at the file the shape moved out of. It now describes what a chunk id is, where it is minted, and why this action has to carry its shape through unaltered even though it neither parses nor requires it. `docs/decisions/meetings.md` counted two barriers on the phone against the desktop's four. On like-for-like terms it is three -- `applyMeetingEvent`'s terminal-state check is one of the four the section above it counts, and the phone has had it all along. Correcting the count is worth doing because it makes the real asymmetry easier to see: the gap was never a number of guards, it is that exactly one guard in this whole feature is enforced by a party other than the client making the request. And the sharper correction, which the section did not say: every one of these guards reads what an id *says*, and an id that says nothing is accepted at every door -- the gateway's included. A client is not stopped by any of them if it simply stops naming meetings in its ids; it does not have to lie. So "a boundary no client can talk past" is exact about misaddressed words and not about unaddressed ones. The acceptance still stays, and the section says why narrowing it is not worth what it costs: the marker that would separate old data from a client that stopped addressing is written by the same binary whose honesty is in question, so it buys nothing against the case it aims at, and it would refuse real meetings in the one direction this contract says a guard on a transcript may not fail in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Ht7GUWMd9t4xDNED9NDEz
lilseyi
force-pushed
the
review/phone-contamination-followup
branch
from
September 8, 2026 04:20
ebdf41a to
a13e3a4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
A post-merge adversarial review of #358 (
f276c58), which was merged by its author while a separate review was in flight. Nothing in #358 should have been reverted — the four things it built are right and its own accounting is honest — but a pre-merge review would have caught four things, and this fixes them forward.Verdict on #358
assertOwnTranscriptis placed where it claims to be (first statement infinalize, before the workspace resolve and the render, and the session objects it reads are rebuilt by an immutable fold, so there is no stale value to satisfy it with); the phone's only meeting-note write really is that onefinalize; a late segment is folded beforeend()detaches, so nothing legitimate is refused.applyMeetingEvent's terminal-state check is one of the four the section above counts for the desktop, and the phone has had it all along. The count was never the point: exactly one guard in this feature is enforced by a party other than the client, and that is the whole gap.files.writeNote. Nothing else. A rendered note carriesmeeting-idin frontmatter and turns as**[clock] speaker** — textwith no per-row ids, so nothing anywhere — at write time or afterwards — could detect two meetings' words in one note.The four fixes
1.
describeEventTypebounded the length and not the shape. The sentence it builds is written to a customer's log file byapps/desktopas the tail of one line (meeting_write_refused session=… kind=… status=… code=…: <sentence>). A newline forges a second line in one character and an escape sequence rewrites the printed one in four; forty characters is no defence against either. The shape is now checked first, the wayassertSafeEtagdoes one folder over. An identifier-shaped type is still named and truncated, because that is the one thing a client author can act on.2. A second meeting was silently swallowed.
MeetingsListScreenhides its record button while a meeting is live; the console's Record key (ConsoleBottomBar) does not, socontroller.start()really can be called for a second meeting while the first is recording. Both recorders returned silently fromstart()and went on minting the first meeting's chunk ids. Before #358 that was contamination with no name on it; since #358applycorrectly refuses every one of those segments and the second meeting records nothing at all and says nothing. The recorder now refuses (ALREADY_RECORDING), whichcontroller.starthandles like a denied microphone — notepad keeps working, sentence on the live screen. Same meeting twice is still one start.3. The zero-failure sabotage row now fails. #358's
acceptsTranscripttest drives every state the table names, but both sides read the same table, so reverting to the old denylist stayed green — reported honestly as 0. The derivation itself is now pinned: a third terminal state is added to a copy of the table through the module registry and the answer for it has to come out of the table. Reverting the source fails here — 1.4. Two half-closed residues. The gateway's own mirror of
acceptsTranscript(ingest.jsnamingcomplete/emptyby hand in three places) was named in #358's commit message and not closed; it cannot be derived as cheaply because each refusal carries its own sentence per state, so the coupling is checked instead — the day the table grows a third terminal state the suite says which three functions must learn about it. AndMAX_CHUNK_ID_LENGTH's comment inapps/convexstill described the ids as<Date.now()>-<index>, which is exactly what #358 stopped being true.Every remaining subscription in the phone's recording path
Checked, and there is nothing further to report as a leak:
controller.listenToRecorderholds both unsubscribes anddetachRecorderruns fromend(),discard(),reset()and the next attach;audio.ts'sopenDevicecallsreleaseDevicefirst, so therecordingStatusUpdatesubscription can never be overwritten while attached;capture/desktop.ts'sattach()detaches first and keeps itsoffs;useAudioLeveland every effect inuseMeetings/useMeetingFlowreturn cleanups. The one place listeners are never explicitly removed isaudio.web.ts'swatch(track)— three per track — and every path out (stop,abandon, theendedhandler, a failedopenChunk) callsreleaseStream(), which stops the tracks, so they are inert rather than detached. Noted, not changed.Sabotage, measured
Each guard reverted one at a time on this branch, its file run, then restored and re-verified green.
describeEventTypeshape checkMEETING_TRANSITIONSALREADY_RECORDING(audio.ts)ALREADY_RECORDING(audio.web.ts)audio.ts)audio.web.ts)acceptsTranscriptderivation#358's own seven rows were re-run first on
f276c58and reproduced exactly as reported: 3, 3, 1, 1, 2, 2, and 0.Verified
pnpm install --frozen-lockfile, then onf276c58(the merged baseline) and again on this branch after rebasing onto the currentmain(e47a770, unrelated calendar work, no conflicts):apps/mobile— baseline 220 suites / 4078 passed / 1 todo; this branch 221 suites / 4103 passed / 1 todo. No flake in any run here (the suite is separately known to have one editor test that flakes only under parallel load).apps/mcp— ALL PASS, 3073 → 3077 PASS lines.apps/desktop— ALL PASS, 1716 PASS lines, unchanged.packages/meetings— ALL PASS, 778 PASS lines, unchanged.apps/convex— 70 files, 2194 passed (one comment touched there).turbo run typecheck— 12/12 clean.turbo run lintfails@context/shared#lint(eslint .→ "No files matching the pattern").packages/sharedis byte-identical tomainon this branch, and CI's ownLintjob is green, so it is pre-existing and untouched here.🤖 Generated with Claude Code
https://claude.ai/code/session_011Ht7GUWMd9t4xDNED9NDEz