docs(records): row 5 round 1 review files, record-issue gap in DEFERRED

Both reviewers requested changes (26681, 26683). Darkwing's verdict
landed on #1508; pointer 26685 on #1507.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-04 14:01:37 -05:00
co-authored by Claude Opus 5.5
parent 14562ac227
commit 8a3e672f4a
4 changed files with 436 additions and 0 deletions
@@ -0,0 +1,160 @@
# Queue row 5, CHAT-03 increment 1, round 1 review (#1508)
Darkwing, 2026-10-04. Request: #1508 comment 26671. My part is the binding
and the extension-load refusal (lead decisions 31 and 36). Brief:
`agents/dewey/work/chat-03/BRIEF.md` at 1ef15ac0. Candidate:
`agents/dewey/work/chat-03/I1-manifest.sha256`, digest
`1404341eaeaf7d1e684c9f27e76718061ed08c25a52da5f190425feb1274ba69`,
27 files, uncommitted.
Verdict: changes requested. Two blocking findings, B1 and B2.
## Checks
- The manifest hashes to 1404341e. All 27 files match it in the canonical
working tree.
- Export: `git archive` of 1c724958 plus the 27 candidate files in
`/tmp/r5-exp`, manifest OK there too. `node --test
packages/conversation/tests/` passes 141/141, 0 skipped.
`node --test packages/control-board/tests/` passes 124/124 (the board
reads `ControlRefusal` codes). The CHAT-00, CHAT-01 and CHAT-01c checks
still pass.
## B1 (blocking). The seal is a deny-list over an argv the caller builds
`checkSeal` (pi-pin.mjs 59–66) refuses `-e`/`--extension`, a missing seal
flag, or a first pair other than `--mode rpc`. Everything else in
`engine.extraArgs` passes, and `buildPiArgs` appends extraArgs after the
controller's own `--session`. Pi's parser keeps the last `--mode` and the
last `--session`. The controller is a public export (`./controller` in
package.json), so this is reachable without touching the source.
(a) `extraArgs: ["--session", <file outside the fixture root>]`. The guard
refuses that same path when it is passed as `sessionFile`, but it never
sees extraArgs. With the real pinned Pi under a scratch HOME and agent dir
(`/tmp/r5/seal-escape.mjs session`):
```
guard on sessionFile: live-session-refused
constructed with extraArgs ["--session",".../outside/proj/.pi/state/other/sessions/s1.jsonl"]
-> piArgs ["--mode","rpc","--no-extensions","--no-prompt-templates","--no-themes",
"--session",".../fx/.../fixture-seat/sessions/s1.jsonl","--session",".../outside/..."]
start -> {"launched":true,"classified":{"state":"free"}} | binding uncertain closed
uncertain evidence: loaded-session, "the engine loaded another session file"
outside session changed: true | appended: {"type":"thinking_level_change","id":"bef67aef",
"parentId":"b2c3d4e5",...,"thinkingLevel":"off"}
```
K8 notices afterwards, but Pi has already written to a session the guard
exists to protect. That breaks §2 "no writes to sessions" and the
fixture-only rule in code.
(b) `extraArgs: ["--mode", "json"]` (or `text`). checkSeal passes because
it looks only at args[0] and args[1]. Pi starts in print mode, reads stdin
to EOF and treats it as the prompt. The K8 `get_state` line goes into that
reader, so the run ends `uncertain` on `get_state: timeout`. In an earlier
run where I closed stdin, Pi sent the `get_state` JSON line to the model
as a prompt and stopped only at "No API key found". The default
`engine.env` is `process.env`, so with real auth present that becomes a
paid model call outside RPC. PgroupLauncher spawns detached, so the engine
outlives the controller unless someone kills the group.
Other extraArgs that pass checkSeal today: `--no-session`, `--fork`,
`--export <file>` (writes a file), `--prompt-template`, `--approve`.
`engine.command` and `engine.preArgs` can also run any script, or node
with `--import`. `checkEnginePin` validates only the pinRoot lock files, so
the pin says nothing about what actually ran.
Suggested fix:
- Allow-list extraArgs. The only non-extension use in the suites is
`["--model", "other"]` (claim.test 548, W9), so `--model`, `--provider`
and `--thinking` with one value each would cover it.
- Refuse any second `--mode`, `--session` or seal flag, and any session
or output flag (`--print`, `--no-session`, `--session-dir`,
`--session-id`, `--fork`, `--export`, `--continue`,
`--resume`).
- Say in the README that a non-default `command` or `preArgs` is a test
hook, and that the pin and seal checks don't bind under it. Or refuse it
outside tests.
- N24 cases for `--session`, `--mode json` and `--no-session` in
extraArgs.
## B2 (blocking). The claim's session key is the conversation ID
controller.mjs 187:
`this.sessionK = sessionKey({ harness: "pi", conversation: this.conversation })`.
`conversation` is `"pi-" + sha256(projectRoot, seat, name)` (reader.mjs
72). The brief (line 382–383) keys the claim on "the native session
identity (the Pi header ID or the Claude session UUID)", and the CHAT-01
README says at most one non-stopped binding may hold a conversation or
native-session identity. Two paths to one session file give two
conversation IDs, so the session key never collides.
Repro, `/tmp/r5/hardlink.mjs`: one session hard-linked into
`.pi/state/fixture-seat/sessions/s1.jsonl` and
`.pi/state/seat-b/sessions/s1.jsonl`, two controllers via the test harness
with the fake engine.
```
same inode: true
A conversation pi-b5901a69... | B conversation pi-f21f1a75...
A start: {"launched":true,...} state active
B start: {"launched":true,...} state active
A nativeSession 0f5e1c2a-1111-4222-8333-944455556666 | B nativeSession 0f5e1c2a-1111-4222-8333-944455556666
```
Two active bindings, two engines, one session. A plain copy is allowed
too; whether a copy should count as the same session is a call for the
brief, but the hard link plainly should. W4 (claim.test 172) builds its
keys by hand on the store, so it never exercises the controller's
mapping.
Fix: build the session key from the header `id` read in `start()` (the
`nativeSession` already in hand). Add a controller-level W4 case for the
hard link, and one for a copy with whatever the brief decides.
## n1 (non-blocking). The startup-append note is narrower than Pi's rule
README 426–431 and BUILD-I1.md 196 say the append bites only sessions
without a `thinking_level_change` entry, and that Pi-created sessions
carry one. Pi's rule is sdk.js 82:
`hasExistingSession = existingSession.messages.length > 0`. A session
with a thinking entry but no messages takes the new-session branch and
appends `thinking_level_change` at every start, plus `model_change` when a
model is set. I checked this in plain sealed RPC mode: a header plus a
thinking entry gained `{"type":"thinking_level_change","id":"e8c74875",
"parentId":"f0e1d2c3",...}`. A Pi-created session that was opened and
never prompted is in this group, so "written by something else" is wrong.
Name message-less sessions too, and let the later pre-spawn check cover
both.
## n2 (minor). The guard follows $HOME
`LiveSessionGuard` protects `~/.pi`, `~/.claude` and `~/.mosaic-dev` via
`os.homedir()`. With a scratch HOME, the real directories are protected
only by the fixture-root containment, or by passing `homes`. That
containment holds today, so I'm noting it, not blocking on it.
## What holds in the binding
- H1 to H3. `#dispatch` holds `this.lock` across `#recheck` and the
write. Takeover, release and acquire run `#evaluate` under the same
lock, so a prompt can't land between the generation bump and the write.
- The generation check comes first in `#evaluateOp` and again in
`#recheck`.
- Takeover refuses while fenced (K9) and for the current controller
(`already-controller`, H4).
- Disconnect never moves control (H11).
- Confirmations are single-use: `#checkConfirmation` marks them
`consumed`.
- Interrupt sets its fence before it takes the lock, so a queued prompt
sees the fence.
- K8 catches a wrong session or leaf after launch, as in B1(a). B1 is that
the write happens before K8 can run.
- The pin check reads both lock files and refuses on either version or
integrity mismatch.
Scratch scripts and outputs are in `/tmp/r5/`: `seal-escape.mjs`,
`hardlink.mjs`, `seal-session.txt`, `seal-json.txt`, `hardlink.txt`,
`conv-suite.txt`, `board-suite.txt`. All scratch engines were killed by
their process group.
@@ -0,0 +1,265 @@
# Queue row 5, CHAT-03 increment 1, round 1 review
Filbert, 2026-10-04. Request: #1507 comment 26671 (Dewey). Brief:
`agents/dewey/work/chat-03/BRIEF.md`, sha256 1ef15ac0…, with lead decisions
30–34 and 36. Candidate: `agents/dewey/work/chat-03/I1-manifest.sha256`,
digest `1404341eaeaf7d1e684c9f27e76718061ed08c25a52da5f190425feb1274ba69`,
27 files, uncommitted.
My part is the whole brief except the binding and the extension-load
refusal, which Darkwing reviews (`pi-pin.mjs`, `claim.mjs`, `guard.mjs`,
`SEAL_BASIS`, the K8 load check, takeover and generation; N24, K6/K8 pin
side, W1–W20, G1–G3, H1–H4, mutants 37/37b).
Verdict: **changes requested.** Six blocking findings, B1–B6. Two are
defects in the terminal, one is a defect in the force-stop chain, and three
are missing tests that leave a brief or lead-decision property unpinned.
The rest are notes. None of my findings repeats Darkwing's B1 or B2.
## How I checked
- Frozen copy in a scratch clone of 1c724958 (`/tmp/f5r1/repo`, push URL
`DISABLED`, candidate files read-only). The manifest verifies there and
in the canonical working tree. Every changed path is under
`packages/conversation/` (Files owned). `git diff 1c724958 -- docs
contracts roles` is empty and the brief's 12 contract hashes match.
- Export: `git archive` of 1c724958 plus the 27 files, manifest OK. Every
§10 suite was run on it, each with its own log:
- conversation 141/141, 0 skipped; control-board 124; webui 14; seat 19;
- CHAT-00 48 checks; CHAT-01 R3 PASS; CHAT-01c PASS;
- auth 15, config 24, discord 64, extension-package 18, foundation 44,
release 14, task 90;
- queue 27/0 with node 148/148. The two live-queue checks skip outside
the canonical root and say so, which accounts for Dewey's 29;
- conductor fails on the export only because the export isn't a git
repository (`fatal: not a git repository`). In the scratch clone
holding the candidate it passes 17/17.
- Three scoped deep reads, each in its own scratch copy: turns (§3, O1–O6,
N1–N25, decision 34); stop and recovery (§6, K1–K18, H10, H17, H20–H23);
transport, slash, terminal and seam (§1, §4, §9, H11–H19, E1–E7). I
reran every blocking result myself against the frozen candidate before
listing it.
## Blocking
### B1. Text typed after Enter in the same input chunk joins the submitted message
`terminal.mjs` 125–133 and 157–158. `key()` appends characters to the
composer at once but queues the Enter action until the loop ends, so
`submit()` reads the composer after the rest of the chunk has gone in.
Rerun on the frozen candidate (`/tmp/f5r1/b1probe.mjs`, a recording fake
client): `key("first\rsecond\r")` sends one prompt, `["firstsecond"]`; the
second Enter sends nothing. `key("abc\rdef")` sends `["abcdef"]`.
This is the concatenation hazard §4 exists to remove ("cleared after each
submit"; README "It clears after each submit"). One chunk holding Enter and
more text is ordinary: tmux `send-keys "x" Enter "y"`, typeahead while the
event loop is busy, or a terminal without bracketed paste. Fix: snapshot
the composer at the Enter (submit that text, clear, then keep reading the
chunk), and add a test with text after Enter in one chunk.
### B2. A paste-start marker split right after its ESC loses paste mode, and an Enter inside the paste submits
`terminal.mjs` 114. The carry needs `s.length - i >= 2`, so a lone trailing
`\x1b` is consumed as an Escape key. Rerun: `key("\x1b")` then
`key("[200~a\rb\x1b[201~")` sends `["[200~ab"]`. Splits at other offsets
inside the marker are handled.
README: "A bracketed paste is inserted literally, newlines included, and
never submits by itself." The read boundary has to fall exactly after the
ESC, so this is rare, but the fix is cheap: a lone Escape has no action in
this terminal, so carrying a trailing lone `\x1b` costs nothing. Add the
split case to the paste test.
### B3. A second force stop runs a parallel escalation and the first one's phase is written onto the second stop's record
`controller.mjs` 657–665 admits `force-stop` while `b.state` is
`stopping`. `#forceStop` for the second stop writes
`stop: { id: <second>, phaseStarted: null }` into the claim, while the
first escalation is still running. The first one's `onPhase` then writes
`{ ...r.stop, phaseStarted: name }`, which is the second stop's record. The
superseded check runs only after `stopCohort` returns (1454), so both
escalations signal, freeze and kill at once.
The stop-and-recovery read proved it with pause hooks (log kept at
`/tmp/f5r1-cohort-logs/XFS2-1.txt`, three runs): the claim shows the second
stop's ID with `phaseStarted: "kill"` while the first is held at the kill
phase and the second hasn't run TERM. I read the code path and it matches.
All three runs ended `stopped` with the child dead, so no false proof was
seen. But §6 makes the stopping revision the record of which stop reached
which phase before any signal, and here it is wrong. Two concurrent
freeze/enumerate/kill passes on one cgroup are also outside what the proof
reasoning covers.
Fix: one force-stop escalation at a time. Either refuse a second
`force-stop` while one is running (it can be retried once the first ends
`uncertain`), or mark the first superseded and wait for it before starting
the second. Add the race to `races.test.mjs` with engine bytes, the claim
revisions and events asserted.
### B4. Nothing tests lead decision 34
Decision 34: "If the code ever sees `aborted` with no stop in progress, it
treats it as an overlap signal. … This goes into the build and its tests."
The check is at `turns.mjs` 163. I replaced that line with a comment in a
copy of the export and ran the whole conversation suite: 141/141 pass
(`/tmp/f5r1/logs/mutant-d34.log`). No test mentions `aborted-without-stop`.
The check itself works: the turns read drove an `aborted` with no stop and
got `["aborted-without-stop"]`, binding `uncertain`, receipt outcome
unknown. Add that as a fixture and add the mutant to the table.
### B5. K12 doesn't prove the freeze
Brief line 1117: "The freeze stops forking. The enumeration is complete."
I made the shim skip the `cgroup.freeze` write and answer `frozen 1`
anyway. The whole cohort suite passes, 18/18, 0 skipped
(`/tmp/f5r1/logs/mutant-C3-cohort.log`). The stop-and-recovery read got the
same result for a freeze that is written but not waited on. K12 asserts
only the end state, and `cgroup.kill` alone reaches that state.
Fix: assert `cgroup.frozen` is 1 before enumeration, and that no member
outside the proof's list existed after the freeze (for example from the
fork loop's own pid log). Add the no-freeze mutant to the table.
### B6. K15 has no "engine path missing" case
Brief line 1120 lists "`engine` cgroup path missing or unreadable, or the
shim gone"; §6 says "an absent `engine` cgroup is an absent observation,
never an empty one." The test covers the shim gone and `chmod 000` on
`cgroup.events` (EACCES), not a missing path. I made the shim's `events()`
answer `populated 0` on ENOENT only. The cohort suite passes 18/18
(`/tmp/f5r1/logs/mutant-D-cohort.log`). The mutation table's 21 is a
different, broader variant that the EACCES case does kill, so I'm not
disputing the table, only the coverage.
The code does fail closed here today, through the TERM-phase enumeration
and the freeze write (the stop read removed the `engine` cgroup and got
`uncertain`, "engine enumeration failed (ENOENT)"). Pin it with a missing
`engine` fixture.
## Notes (not blocking)
Turns:
- **n1.** An `aborted` that arrives after the fence but before the first
abort is written is linked to the stop: receipt `failed`, reason
`interrupted`, no overlap signal, binding stays `active` (stop outcome
Unknown, so nothing reconciles). Decision 34's wording ("no stop in
progress") allows this, but under the seal an `aborted` before the
controller's abort isn't the controller's. Recommend linking only after
an abort was written (`ctx.abortIdxs` non-empty).
- **n2.** An O5 that arrives in the same chunk as the empty clear response
is recorded before the abort, yet the abort is still sent and the queued
item runs (`controller.mjs` 1278–1284; the overlap check is only at the
top of each round). N6 accepts this outcome. Rechecking `tr.overlapped`
after the `before-abort` pause costs one line and fits rule 2's reason.
- **n3.** On a non-empty clear or an abort failure, `stop.nativeQueue`
keeps its initial value instead of `unknown`. No turnProof is built on
any uncertain path, so "the turnProof records `nativeQueue: unknown`"
(rule 2) lives only on the stop record.
- **n4.** After a K9 no-settle the stop is `uncertain`, so a later
`aborted` from the controller's own abort raises `aborted-without-stop`.
After a force stop supersedes an Interrupt, an Interrupt-caused `aborted`
links to the force-stop ID. Labels only.
- **n5.** Test gaps: `rounds-exhausted` has no test; N25 asserts only that
O5 is present; no test for O5 from a `get_state` pending count, or O2
"before the slot's ack" and "a second settle for one ack".
- **n6.** A user `message_start` with no current run and an `agent_end`
with no open start are counted but raise no signal. Not in O1–O6, so not
a brief violation.
Stop and recovery:
- **n7.** Resuming from TERM after a restart isn't asserted: skipping TERM
when `phaseStarted` is `kill` passes K10 and K11. K3 doesn't assert TERM
was delivered.
- **n8.** K13: the README says the refusal is "not a controller check", but
the cgroup namespace is the shim's `unshare --cgroup` (removing it fails
K13). Nothing checks at runtime that cgroup2 is mounted `nsdelegate`; on
a host without it, stops would still record `stopped`. The brief only
asks that K13 shows the refusal on this host, so this is a portability
note for the cutover increment.
- **n9.** A member can ask the user systemd manager for a new unit outside
the scope (`systemd-run --user` from inside a user and cgroup namespace
lands in `app.slice`). Same-uid escape, Limits 3/4; worth a line in the
packet's limits.
- **n10.** The shim channel is not authenticated: the engine sees the
scope's `INVOCATION_ID` and can reach the shim socket, so a same-uid
engine that kills the shim could answer as it. Limit 1; the claim could
record the shim's pid and start time to narrow it.
- **n11.** An orphan's tool map is taken at restart, not at stop time.
Low impact.
- **n12.** H17 tests a confirmation used after the stop changed, not one
answered after it changed; answering doesn't check the stop, using it
refuses, so the outcome matches. A freeze or kill timeout leaves the
cgroup frozen (fails closed).
Transport, terminal, seam:
- **n13.** `visible()` passes U+061C (a bidi control the README says is
made visible), U+200B and other zero-width characters, U+FEFF, tag
characters U+E0000–E007F and LF in the status and head lines. None can
drive the terminal; they can hide or spoof text.
- **n14.** `LineSplitter`'s limit isn't checked for a line whose LF arrives
in the same chunk: with `maxBytes` 100 a 590-byte line is accepted. The
overshoot is at most one chunk.
- **n15.** For I3: the 5 s ack timeout can be short for real Pi, whose
`prompt()` may refresh OAuth or run auto-compaction before it acks. That
fails closed (poison, force stop) but the fake doesn't model it.
- **n16.** Compaction summaries are rendered on pages but not streamed, and
a quiet cut doesn't re-read after `run-settled`, so a summary appears
only after a reconnect or Ctrl-O (E4 "exactly once").
- **n17.** Two interrupts with one request ID in flight: the second gets
`fenced` instead of the first's outcome. The client has no
`onOverflow`, and the controller's socket writes have no backpressure.
- **n18.** Test gaps: no test that a socket directory that isn't 0700 or is
a symlink refuses; none for an overlong or split multibyte engine line;
the escaping test covers only ESC, BEL and U+202E; H12 and H13 don't
assert events (brief line 962: "Every race asserts the engine bytes, the
receipts and the events").
README:
- **n19.** "The scope launcher stops the cgroup with SIGKILL, because the
shim ignores SIGTERM by design" contradicts the code, which runs the
brief's TERM phase on `engine` members and then freeze and `cgroup.kill`.
The shim ignoring SIGTERM only keeps the scope's anchor alive.
- **n20.** The pieces table says the shim "answers `proof`"; its ops are
hello, events, members, term, freeze, kill and release.
## Checked and found correct
- Rule 4 receipt table, rule 5 stop outcomes, rule 6 reopening only on
Interrupted with every reconcile condition required; O1–O6 present; clear
failure sends no abort; `no-turn` lifts only its own fence; N1–N25 each
have a test.
- Force stop: the stopping revision before any signal; the invocation ID
checked through systemd and the shim; TERM phase then freeze, enumerate,
`cgroup.kill`, `populated 0`; `membershipComplete` only after all of
them; unavailable evidence ends `uncertain`; the pgroup fallback never
reaches `stopped`; restart re-runs from TERM.
- Recover: verified proof, unchanged pins and leaf, consumed confirmation,
single-use eligibility bound to the incarnation, launch rechecks it
(K6, K7, K17, K18). Foreign host refused, boot proof verified.
- Framing splits bytes at LF only and survives multibyte splits; the socket
directory is 0700, lstat-checked, owned, before listen; connections start
as observers; `stale-incarnation` first; the client never resends;
dedup by actor, conversation and request ID with `conflicting-request`;
`busy` decided under the lock; three write outcomes with poisoning; no
`streamingBehavior`, steer or follow-up is ever sent.
- Slash policy matches pinned Pi (`rpc-mode.js` 298–318,
`agent-session.js` 821–948): only a leading `/`, `/skill:` and templates
are interpreted, Pi doesn't trim, so refusing after whitespace is
conservative; `!`, `!!`, `@` and later-line `/` are admitted correctly.
- The quiet-cut rule holds for runs: Pi persists `message_end` before
`agent_settled`, the page read is under the lock.
- `ControlRefusal`, the four new package exports and the cross-package
`journal.mjs` import (same pattern as `packages/queue`).
## Round 2
I'll review the touched files and rerun the mutants above (decision 34,
no-freeze, ENOENT-as-empty) plus the B1–B3 probes against the round 2
candidate.