packages/conversation is a library with no server: safe-fs, the Pi session parser, CHAT-01 pages, pinned snapshots, cursors and follow. The control board adds GET /api/conversations and /api/conversation behind the Host and Origin guard. Both are read-only, their queries are validated, and each refusal code maps to a status. Dewey authored it (packet 0cf177b1, revision 2). Filbert reviewed the code: R1 revise (branch ids moving on append, the assumed-link bridge merging branches, one unreadable seat directory turning the catalogue into a 500), then R2 approve (3b14d66c). Darkwing reviewed the routes: R1 approve (07b10ad1), R2 approve (b9d92003). The package lands with the routes, because serve.mjs imports the reader at load. On an index export: the eight suites 24/90/43/17/14/15/63/18, conversation and control-board 153/153, webui 9/9. Co-Authored-By: Claude Opus 5.5 <[email protected]>
11 KiB
CHAT-02 backend: Filbert's code review
Reviewer: Filbert, 2026-09-26. Requested by Dewey, assigned by Sage (D4).
Candidate: agents/dewey/work/chat-02/BACKEND.md, sha256
286c3ad5f147471f16ef88537c7680c5bd6130e6365ce1f0c4cb98cd74e75c2e, and the
nine files its §1 pins. All nine verify. Measured against BRIEF.md R4
(636b0fac…, verified) and Sage's D1–D4 (2026-09-26_lead-decisions.md
item 8). Jason's go on CHAT-02 is recorded in the same file (20:31Z).
Verdict: revise. The safe-open path, snapshots, cursor refusals, byte limits and the no-write discipline are sound, and the tests are strong. Three defects need fixing, and I reproduced each with a scratch script (§2). Three small items can go in the same revision (§3). No finding needs a lead ruling.
1. What I checked and reproduced
-
Suites. The shared working tree now carries unpinned edits to
packages/webui/src/public/app.js,index.htmlandpackages/ledger/src/ledger.mjs, and four webui browser tests fail there. To test the candidate alone, I ran a detached worktree at HEAD1c5f6bc3with only the nine pinned files overlaid (pins re-verified in place). The result: conversation, control-board, webui and seat, 175/175.chat-00,chat-01andchat-01ccheck.mjsall exit 0. Dewey: the shared-tree failures come from the unpinned webui files, not from this packet. -
Real data, read-only. Catalogue: 18 Pi rows in 71 ms. Following the three most recently active views took 1, 33 and 177 ms per poll, with the cache thrashing across three views. That is acceptable for §3 item 9.
-
Pi semantics against the pinned 0.85.1
session-manager.js:- the default leaf is the last entry in file order (
_buildIndex); branch()moves the leaf in memory only;resetLeaf()starts a new root entry, so a file can have several roots;retainedTailis documented indocs/session-format.md245.
The parser matches all four.
- the default leaf is the last entry in file order (
-
Safe open. It opens with
O_NOFOLLOW, compares (dev, ino) after thefstat, re-checks the components after the open, and reads only from the descriptor. Registrations never add a root or name a file, and a spec must have the exact.pi/state/<seat>/sessionsshape. The listing refuses symlinks and odd names without opening them. -
Accepted choices, packet §3:
- item 1, the summary row;
- item 2,
sessionsDirmatching, which is stricter than the brief'ssessionFile; - item 3, the placeholder row;
- item 4, the epoch comparable within one chain, which README 132–135 states;
- item 7, apart from finding 3 below;
- items 8, 9 and 10.
Item 5 stands, apart from finding 1. Item 6 does not stand: see finding 2.
2. Required
- A branch's id changes every time the conversation grows.
- The branch id is the leaf entry's id (
reader.mjs270). Every append makes a new leaf, so one thread is named differently from one page to the next. - Scratch file: open a linear
a→b, then appendc→d.- The open page has
branch: "b". - The follow, sent with
branch: "b"on a cursor bound tob, returns a page withbranch: "d", and its entries carryd. open({branch: "b"})then refusesunknown-branch(404, reconcile).
- The open page has
- The F12 test asserts this behaviour (
g.page.branch === "o5"after followingo4). - Effects:
- One view's entries carry different
branchvalues. - A cursor bound to one branch serves a page labelled with another, although CHAT-01 line 66 binds a cursor to its branch.
- Any branch id the Console keeps goes stale after one append. After a
board restart (
cursor-unknown), reopening the stored branch fails.
- One view's entries carry different
- Fix: give each branch an id that growth cannot change. Recommended rule:
- walk from each root in file order;
- at a fork, the child earliest in file order continues the current branch, and each later child starts a branch named after its own entry id;
- the first root names the first branch;
- each later root (
resetLeaf) starts its own branch.
- Appending never changes which child came first, so ids are stable.
view.brancheslists each branch's current leaf and last activity.page.branchalways equals the cursor's branch.- Tests:
- a follow keeps the branch id;
- a pre-growth id still opens;
- entries across follows share one
branchvalue; - F12 with the new ids;
- a two-root file.
- The branch id is the leaf entry's id (
- The "assumed link" bridge merges branches.
branchPath(pi.mjs94–104) joins a missing parent to the previous valid entry in the file whenever a malformed line sits between them.- Scratch file:
a(root),b→a, then a malformed line that heldX→a(a fork froma), theny→X.view.brancheslistsbandyas separate branches.- The default page for
ystill showsroot, thenON THE OTHER BRANCH(b's text), then the notice, thenleaf. That is b's history rendered as y's.
- F12 says "no merge", and the brief's F1 asks only that reading continues. A notice saying the link is assumed doesn't make the merged history correct.
- Nothing is lost without the bridge. The entry before the gap has no children, so it is already a leaf, and the history before the gap reads as its own branch.
- Fix: drop the bridge. Stop with the missing-parent notice and name the unreadable line(s) in it.
- Tests: update the two F1 bridge tests, and keep a follow case in which the view stays put and reports the new default.
- A directory component without search permission fails the whole
catalogue.
lstatOrNull(safe-fs.mjs38–45) rethrowsEACCES, socheckRootthrows a plain error instead of a refusal.catalogue()rethrows it, and the route returns 500.- Scratch file: two seats, with
.pi/state/badat mode 000.catalogue()throwsEACCESon lstat ofbad/sessions.open()of an unknown id throws too, and so does any id resolved after the bad root.
- Packet §3 item 7 and the test at line 639 cover the sessions directory at mode 0300 (search allowed), not a component above it.
- Fix: map
EACCES/EPERMinlstatOrNullthroughdenied(). - Test: a seat directory at mode 000 beside a readable one. The catalogue
lists the readable root and one
unreadableentry inrefusedRoots. Opens in the readable root still work in either root order.
3. Small, same revision
- Redacted thinking is marked visible. Pi stores Anthropic
redacted_thinkingas{type: "thinking", thinking: "[Reasoning redacted]", redacted: true}(pi-aianthropic-messages.js445–451). The parser gives itpermitted-visiblewith that placeholder. CHAT-01 says unavailable reasoning has empty text. Mapredacted === truetounavailablewith"", and never readthinkingSignature. There are no live cases today. - A relative header
cwdresolves against the board's working directory.cwdInProjectcallsresolve(cwd). Withcwd: "."and the board started from the checkout, the file passes. Pi writes absolute paths. Refuse a non-absolutecwdasforeign-project, and add a test. - A file whose entries are all malformed shows no notices. With no leaf,
builduses an empty path (reader.mjs272), soview.unreadableLinesis N and the page has no parts. Emit the malformed notices when there is no leaf.
4. Not findings
- The F8
fstatmismatch is covered by design only. That is acceptable, as packet §5 says. - Cursors are reusable until expiry or eviction. A local process can evict another view's cursors by opening 1000 pages, which ends in a reconcile. That fits the one-actor loopback scope.
- Darkwing reviews the route code. I read it only as far as the reader calls it: the query validation, status map and 500 path look right.
To reach approve
Fix §2 items 1–3, with their tests. Take §3 items 1–3 or say why not. Re-pin, re-run the suites and the mutation scripts, and send the new packet hash. I'll review the delta.
Reproductions: agents/filbert/work/chat-02-backend-review-evidence/
branch.mjs (§2 item 1), bridge.mjs (item 2) and perm.mjs (item 3).
Each builds its own temp project and imports the working-tree reader. Run
them with node <file>.
Revision 2 delta review
Candidate: BACKEND.md sha256
0cf177b14cfdc52b7026d67333dc16eef345d80a7d908d50d69bb4301050ca85, and the
nine files its §1 pins. All nine verify. Revision 1 is frozen as
BACKEND-r1-286c3ad5.md (hash verified). I diffed safe-fs.mjs, pi.mjs,
reader.mjs and serve.mjs against my revision-1 snapshot.
Checks:
- Suites. Detached worktree at
1c5f6bc3with only the nine files overlaid: 181/181.chat-00,chat-01andchat-01cexit 0. - My reproductions, rerun on the candidate:
branch.mjs: open and follow both givemain, and the pre-growth reopen works.bridge.mjs:b.yshows the missing-parent notice (naming line 4) andleafonly.mainstays separate.perm.mjs: the catalogue returns the readable row, withbadinrefusedRootsasunreadable. An unknown id refuses instead of throwing.
- A new probe (
fork.mjs): a linearmain, then a fork from its middle, then growth back onmain, then a second root.- Every branch name held across all of it:
main,b.x,b.r. - Follows stayed on their branch, and returned an empty page when the growth was elsewhere.
view.defaultBranchtracked Pi's last entry.openby name still worked afterwards.
- Every branch name held across all of it:
- Naming rule. At most one leaf can end each earliest-child walk. For that reason, two leaves cannot share a name. For a file that only grows, a name cannot change: children are sorted by file index, and the first root is fixed.
- Small items. Redacted thinking gives
unavailablewith""; a relativecwdisforeign-project; an all-malformed file gives one notice per line. Each has a test. - Malformed notices on every branch, after the leaf too. This keeps a branch's served parts a prefix of its later parts, because new lines only ever append at the end.
Verdict: approve 0cf177b1.
One nonblocking note: the new §5 limit, where a duplicate id with new
content goes unnoticed, could be closed cheaply. idsDigest could hash the
serialized parts, whose sizes are already computed, instead of their ids.
Pi's generateId never reuses an id, so this can wait.
Darkwing owns the route delta. I read only the serve.mjs diff: the cursor
without branch gives a 400, and there are three new REFUSAL_STATUS
entries. It looks right.