Skip to content

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
mainfrom
review/phone-contamination-followup
Open

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
lilseyi wants to merge 4 commits into
mainfrom
review/phone-contamination-followup

Conversation

@lilseyi

@lilseyi lilseyi commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

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

  • Nothing merged should not have. The chunk-id change is correct and safe against already-stored ids; assertOwnTranscript is placed where it claims to be (first statement in finalize, 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 one finalize; a late segment is folded before end() detaches, so nothing legitimate is refused.
  • Degrade-to-bypass: yes, and it is not phone-specific. Every guard here 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 these by lying; it is not stopped by declining to say. So "a boundary no client can talk past" is exact about misaddressed words and not about unaddressed ones. Argued in the decision doc, including why the acceptance still stays (the marker that would separate old data from a client that stopped addressing is written by the same binary whose honesty is in question, and refusing on it would fail in the one direction the contract forbids).
  • Barrier count. Three client-side on the phone, not two — 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.
  • What the server sees when a phone files a meeting note: a workspace id, a path and a body, through files.writeNote. Nothing else. A rendered note carries meeting-id in frontmatter and turns as **[clock] speaker** — text with no per-row ids, so nothing anywhere — at write time or afterwards — could detect two meetings' words in one note.

The four fixes

1. describeEventType bounded the length and not the shape. 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 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 way assertSafeEtag does 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. MeetingsListScreen hides its record button while a meeting is live; the console's Record key (ConsoleBottomBar) does not, so controller.start() really can be called for a second meeting while the first is recording. Both recorders returned silently from start() and went on minting the first meeting's chunk ids. Before #358 that was contamination with no name on it; since #358 apply correctly refuses every one of those segments and the second meeting records nothing at all and says nothing. The recorder now refuses (ALREADY_RECORDING), which controller.start handles 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 acceptsTranscript test 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.js naming complete/empty by 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. And MAX_CHUNK_ID_LENGTH's comment in apps/convex still 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.listenToRecorder holds both unsubscribes and detachRecorder runs from end(), discard(), reset() and the next attach; audio.ts's openDevice calls releaseDevice first, so the recordingStatusUpdate subscription can never be overwritten while attached; capture/desktop.ts's attach() detaches first and keeps its offs; useAudioLevel and every effect in useMeetings/useMeetingFlow return cleanups. The one place listeners are never explicitly removed is audio.web.ts's watch(track) — three per track — and every path out (stop, abandon, the ended handler, a failed openChunk) calls releaseStream(), 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.

Guard Sabotage FAIL
describeEventType shape check dropped, length bound kept 2
Terminal-state coupling a third terminal state added to MEETING_TRANSITIONS 1
ALREADY_RECORDING (audio.ts) silent return for a different meeting again 1
ALREADY_RECORDING (audio.web.ts) same 1
Same-meeting no-op (audio.ts) throw for the same meeting too 1
Same-meeting no-op (audio.web.ts) same 1
acceptsTranscript derivation back to the old denylist 1 (was 0)

#358's own seven rows were re-run first on f276c58 and reproduced exactly as reported: 3, 3, 1, 1, 2, 2, and 0.

Verified

pnpm install --frozen-lockfile, then on f276c58 (the merged baseline) and again on this branch after rebasing onto the current main (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.
  • CI on the rebased head: 24 success, 2 skipped, 0 failed.
  • One local-only note: turbo run lint fails @context/shared#lint (eslint . → "No files matching the pattern"). packages/shared is byte-identical to main on this branch, and CI's own Lint job is green, so it is pre-existing and untouched here.

🤖 Generated with Claude Code

https://claude.ai/code/session_011Ht7GUWMd9t4xDNED9NDEz

`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
lilseyi force-pushed the review/phone-contamination-followup branch from ebdf41a to a13e3a4 Compare September 8, 2026 04:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants