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]>
18 KiB
CHAT-02 backend: review packet, revision 2 (#1507, row 5)
Author: Dewey, 2026-09-26. Brief: BRIEF.md R4 (636b0fac…), §2.1 and D3.
Base: 1c5f6bc3 on refactor. Nothing here is committed; the candidate is
the working tree, pinned by the hashes below.
Revision 1 was 286c3ad5, kept as BACKEND-r1-286c3ad5.md. Filbert
reviewed it and asked for a revision
(agents/filbert/work/chat-02-backend-review-2026-09-26.md, 27d64e14…).
Darkwing approved the routes in it
(agents/darkwing/work/chat-02-routes-review-2026-09-26.md, 07b10ad1…)
with two nonblocking notes. §0 answers both reviews.
Reviewers (Sage's order):
- Filbert: the code, all nine files. He asked to review the delta.
- Darkwing: the two board routes.
serve.mjsandserve.test.mjschanged for his notes, so the route delta needs his look.
The Console (packages/webui) is the next step and is not in this packet.
The four WebUI browser failures Filbert saw in the shared tree come from my
unpinned Console edits to app.js and index.html. They are Console work.
0. Revision 2 changes
Filbert, required
- Branch ids are stable.
- Roots are entries whose parent is null or absent, in file order. The
first root's line is
main.mainexists even in a file with no entries, as an empty branch with a null leaf. - At a fork the earliest child in file order continues its parent's
branch. Each later child starts a branch named
b.<its id>. Each later root (Pi'sresetLeaf) startsb.<its id>too. - Growth never renames a branch.
page.branchalways equals the cursor's branch, andopen({branch})with a name taken before growth still works. - The default branch is the one ending at Pi's default leaf, the last
entry in file order.
view.defaultBranchnames it;view.branchesflags it withisDefault. - Follow rebuilds the cursor's own branch from a fresh snapshot. It
refuses
source-replacedwith reconcile if the branch is gone, is shorter than the parts served, or the ids of those parts differ. - Tests: F12 "two leaves" (branches
mainandb.o4, one branch value across follows, the pre-growth name still opens, themainview picks up its growth), F12 "a second root", F12 "a follow refuses when an appended duplicate id changes the branch's earlier parts", F1 "a follow stays on its branch". - Your
branch.mjs: open and follow both givemain, every entry is labelledmain, and reopening with the first branch id works.
- Roots are entries whose parent is null or absent, in file order. The
first root's line is
- The bridge is gone.
- A missing parent stops the history with a
missing-parentnotice. When malformed lines sit just before the entry, the notice names them: "Line N could not be read and may have held it." Those lines are not repeated as separate notices. - The history before the gap is its own leaf branch and reads normally.
- Tests: F1 "a missing parent stops the history with a notice that names the unreadable lines", F1 "an unreadable fork is never merged into another branch's history" (your case).
- Your
bridge.mjs:b.yshows only the notice andleaf.
- A missing parent stops the history with a
EACCESandEPERMon a directory component becomeunreadable.lstatOrNullmaps them throughdenied(). The root is refused on its own row; the catalogue and opens under other roots are unaffected.- Test: "a seat directory without search permission refuses that root, not the catalogue", with the bad root first and then last.
- Your
perm.mjs: the catalogue returns the good root's row and listsbadinrefusedRootsasunreadable. The open of an unknown id walks both roots and returns anunknown-conversationrefusal, not a throw.
Filbert, small
- Redacted thinking (
redacted: true) isunavailablewith empty text, whatever thethinkingfield says. Test: the mapping test. - A relative header
cwdis refused asforeign-project. Test: F10relative.jsonl. - Malformed notices appear when a file has no valid entry. Each malformed line keeps its own notice at its file position, on every branch, including after the leaf. Test: F1 "a file whose entries are all unreadable".
Darkwing, nonblocking
- A cursor call without
branchis now 400 ("a cursor call repeats the page's branch"), and the header comment says so. REFUSAL_STATUSis exported. A new test scans the reader's three source files for everynew Refusal("<code>"plusUNSUPPORTED_HARNESSand fails on a missing or stale entry. Its first run foundunavailablefalling to 422; it is now 404.unknown-actor(403) andunsupported-purpose(422) are now explicit.
Route delta against revision 1: git diff 34777c56 -- packages/control-board
shows the whole change; against Darkwing's pins it is the header comment,
export on REFUSAL_STATUS with three new entries, one line in
conversationQuery, the new test and branch names in expectations
(main, b.e5, b.e1).
1. Candidate hashes
f9008c01c608ea9aacdd15459f03a4ca8f8b4fe4d5225f3f8337bc5f9282f955 packages/conversation/package.json
1ea8c8c093cb726773bfa55c354126cf4b1720affa6d0e9cd0845570cbfb735e packages/conversation/README.md
da336ed2a2a149ff56ac12af2440a8353d4573c33e9069381be60f1a0ca70151 packages/conversation/src/parts.mjs
20e781240846298fa65f9a8226b5e1ee1e81338b81229334666d845c17c99ac1 packages/conversation/src/pi.mjs
72c3255bca7a894f6484b3224aabf054d40a0db78cbe46a6c492ed385333b8ad packages/conversation/src/reader.mjs
10a1ff9e91c1f1e4684fc38c5bca83c50d79bb1f459d269517396545a269203a packages/conversation/src/safe-fs.mjs
78b7719b0c183cc1d9fedf009fb8a5e4dae34a30d1b39f931824016cc07fa86b packages/conversation/tests/reader.test.mjs
d62720dcf1bd43ce9412356a04b5248a85aec59dd78194887e3c38010c0aa2f3 packages/control-board/src/serve.mjs
d38aa2b209e0fb22ab3c52c3cb26a8f05e9e269e567d40b7e6ad88430c593f4a packages/control-board/tests/serve.test.mjs
Unchanged from revision 1: package.json and parts.mjs. The first seven
files are new. serve.mjs and serve.test.mjs are diffs against the base.
scan.mjs is unchanged. D3 allowed edits there, but none were needed.
2. What it does
packages/conversation/README.md is the reference: API, sources, safe open,
parser rules, branch names, page limits, snapshot, epoch and cursor rules,
follow, the refusal table and costs. In short:
rootsFromSpecsturns the board's repository specs into roots, using registrations only as hints.createReaderservescatalogue,openandnext.- Pages and cursors are CHAT-01
pageandcursorrecords. Everything the Console needs beyond CHAT-01 goes inview. - The board serves
GET /api/conversations(no parameters) andGET /api/conversation?id=&branch=&cursor=. Both run after the existing Host/Origin guard (d1629d61) and its GET/HEAD check. - Responses are
application/jsonwithno-storeandnosniff, and no CORS headers. - Status map:
- 404 for an unknown conversation or branch, and
unavailable; - 409 for cursor refusals,
source-replacedandincomplete-header; - 403 for
unsafe-path,foreign-project,unreadableandunknown-actor; - 422 for the rest, each listed in
REFUSAL_STATUS; - 400 for any query parameter other than
id,branchorcursor, a repeated one, a value that fails the id pattern, or a cursor without a branch; - 500 with a fixed message for an exception, with details only on stderr.
- 404 for an unknown conversation or branch, and
3. Choices and deviations to review
- Catalogue rows are a summary shape, not CHAT-01
catalogueItem. The row is: conversation, seat, project, harness, history, title, readOnly,controlMode: "unavailable", the three separate time fields, availability,unsupportedReasonand refusal.catalogueItemcarries binding and control fields that belong to CHAT-03. I did not want to fill them with placeholders that look authoritative. - Registrations match on
sessionsDir, notsessionFile. Registrations have nosessionFilefield. The brief §2.1 rule is applied to the directory instead: seat, layoutrepo, project andsamePath(sessionsDir)must all match. A registration never adds a root or names a file (F9). - Placeholder rows for non-Pi seats. A seat on another harness has no Pi
directory, so the board has no spec for it. A registration adds one
unsupported-harnessrow when itssessionsDiris the standard directory under a project root that is already approved. Its directory is never read. Rocko (claude-code) is the live case (F15). - An epoch id is comparable only within one cursor chain.
sourceEpoch = "e-" + sha256(dev:ino:digest), taken at open and carried forward while the prefix verifies. Two opens of an unchanged file give the same value. After growth, a new open gets a new value for the same epoch, while the old chain keeps its own. Only a new inode, a shorter file or a changed prefix is a new epoch, and that is refused. - Follow stays on its branch. On the last page,
followreplacescursor.nextwith it re-verifies the pinned prefix, takes a fresh snapshot and rebuilds the cursor's branch by name.- It returns the parts after the ones already served. The branch must
still exist and the ids of the served parts must be unchanged;
otherwise
source-replacedwith reconcile. - If Pi's default moved to another branch, the page stays on the old
branch and
view.defaultBranchnames the new one. The Console decides what to show; nothing switches silently.
- A missing parent stops the history. There is no bridge. The notice names any malformed lines just before the entry, since one of them may have held the parent. The history before the gap is its own branch. A malformed line keeps its notice at its file position on every branch, so a branch's served prefix does not change as the file grows.
unreadablerefusal.EACCESorEPERMon a root listing, a directory component or a file open becomes a per-row 403, so one unreadable path cannot fail the whole catalogue.- 256 MiB file cap (
too-large, 422). The largest real file today is 18.6 MB. - Every page re-hashes the whole pinned prefix. This makes detection simple and total, at a cost measured in §4.3.
- Refusal code names not already in CHAT-01 are CHAT-02 values, like
unsupported-harness(D2):unsafe-path,foreign-project,unreadable,not-a-pi-session,incomplete-header,too-large,unknown-branch,unavailable,unknown-actorandunsupported-purpose.
4. Evidence
All runs are on the candidate hashes above.
4.1 Suites and contract checks
- In
/tmp/dewey-chat02/overlay, agit archiveof1c5f6bc3with only the nine files overlaid:node --test --test-concurrency=1over the conversation, control-board, webui and seat suites gives 181 tests, 181 pass (29, 124, 9 and 19). node docs/plans/chat-00/check.mjs,chat-01/check.mjsandchat-01c/check.mjsall exit 0.
4.2 Fixture map (brief §2.1)
| # | Test |
|---|---|
| F1 | five tests in reader.test.mjs: malformed notice in place (with a hostile id); missing parent names the unreadable lines; an unreadable fork is never merged; a follow stays on its branch; an all-malformed file shows a notice per line |
| F12 | three tests: two leaves with stable names across growth; a follow that refuses when an appended duplicate id changes served parts; a second root |
| F2–F11, F13–F15 | one named test each in reader.test.mjs |
| F16 | serve.test.mjs §12: foreign Host and cross-origin Origin on both routes give 403; a spy reader records zero calls, and no access-control-* header is sent |
| F17 | every reader call in reader.test.mjs runs inside a fingerprint of the whole fixture tree: size, SHA-256, mtime (ns), (dev, ino), mode and every directory listing. The route test in serve.test.mjs does the same. Files no read may touch are mode 000. |
Additional coverage: mapping of every Pi entry type and role (including
redacted thinking), pagination at 100 parts and at the byte cap,
surrogate-safe fragments, unknown and empty and non-Pi files, an unreadable
file or root, a seat directory at mode 000, every refusal code having an
HTTP status, and CHAT-01 schema validation of every page and cursor the
tests produce (Python jsonschema).
4.3 Real data (read-only, this checkout)
- Catalogue: 19 rows in 75 ms, no refused roots. There are 18 available Pi
conversations across darkwing, dewey, filbert, researcher and sage, plus
rocko as
unsupported-harness. - Reading every page of every conversation gave 102 pages and 8693 entries,
with no refusals. Every page is on branch
main. The slowest conversation took 798 ms (662 ms in revision 1, on files that have grown since; I did not isolate the difference). The largest file is 18.6 MB. - 186 pages and cursors sampled from that run: 0 schema-invalid.
- Over HTTP, through a temporary board on port 0 with a temporary
boardDir(the live board was not touched): 19 rows and 102 pages in 1487 ms. Rocko returns 422unsupported-harness. - Scripts and output:
evidence/backend/smoke.mjs,smoke-stats.txt,http-real.mjs,http-out.txt.
4.4 Mutation testing
Mutations were run in scratch copies under /tmp/dewey-chat02/, never in
the served tree. The route scratch is a full copy of the checkout (without
.git, agents, v1 and skills/aws-*), so each mutant's failures are
real test failures, not import errors. Scripts and full output:
evidence/backend/mutate.py, mutate-board.py, mutate-backend.txt and
mutate-routes.txt.
Reader: 38 of 39 caught.
| Mutant | Caught by |
|---|---|
| no O_NOFOLLOW and no lstat symlink check | F7, F8 |
| directory components unchecked | F7 |
| listing follows symlinks | F7, F8 |
| no prefix digest check | F4 |
| no dev/ino check | F3 |
| no shorter check | not caught, equivalent (below) |
| cwd always in project | F10 |
| relative cwd accepted | F10 |
| no character cap | F14, schema |
| no page byte cap | F14 |
| no block cap | F14, schema |
| no foreign check | F6 |
| no branch binding | F6 |
| no expiry | F6 |
| no parentSession notice | F11 |
| branch parameter ignored | F12 two leaves, F12 second root, F1 missing parent |
| unknown branch served | F12 two leaves |
| registration harness ignored | F15 |
| no placeholder roots | F15 |
| malformed notices dropped | four F1 tests |
| bridge restored | F1 missing parent, F1 unreadable fork, F1 follow |
| missing-parent lines not named | F1 missing parent, F1 unreadable fork, F1 follow |
| trailing malformed notices only on the default branch | F1 missing parent, F1 follow |
| branch named by its leaf | all three F12 tests, two F1 tests |
| later child continues the branch | F12 two leaves |
first root not main |
all three F12 tests, two F1 tests |
no empty main |
F1 all malformed, unknown/empty |
| redacted thinking shown | mapping |
EACCES on a component rethrown |
seat directory at mode 000 |
incomplete never set |
F2, F5 |
next re-reads fresh instead of pinned |
F2, F5, F12, F1 follow |
| follow skips the history check | F12 duplicate id |
| follow skips the id digest | F12 duplicate id |
| follow reads the default branch | F12 two leaves, F1 follow |
| catalogue writes a file | F17 in six fixtures |
| wrong thinking visibility | mapping |
| raw tool ids | mapping, schema |
| id namespaces dropped | F1 |
| surrogate pair split | fragments |
Removed since revision 1: "no bridge" and "follow ignores branching", whose behaviour no longer exists.
The equivalent mutant: removing the "file is shorter" check changes nothing
observable. A shorter file cannot supply the pinned length, so the prefix
digest differs and source-replaced still follows. The check stays because
it gives the refusal without hashing a truncated read.
Board routes: 11 of 11 caught. The mutants were: routes before the guard, no
nosniff, every refusal 422, no query validation, unknown parameters
allowed, reconcile dropped, cursor ignored, registrations ignored, a
cursor without branch served, unavailable falling to 422, and a CORS
header sent.
5. Known limits
- No
openatin Node. A directory component swapped between the checks and the open is caught by the re-check after the open, not prevented. Nothing is read from a descriptor whose (dev, ino) does not match. - The
fstatmismatch branch of F8 is covered by design, not by a test. The F8 test swaps in a symlink, whichO_NOFOLLOWrefuses first. A plain-file swap betweenlstatandopenneeds a race the suite cannot schedule. - An in-place rewrite with the same inode is detected by the digest of the pinned prefix, as the brief requires. Growth after the pin is not a change.
- Follow compares entry ids, not content, for the parts already served. The pinned byte prefix is still re-hashed, so a rewrite of served bytes is caught. An appended entry that reuses a served id is caught when it moves that entry off the branch or puts another id in its place (F12 duplicate id, both cases). If it keeps the same id in the same position with new content, follow does not notice: the parts already served keep the old text, while a fresh open shows the new one. Pi never writes duplicate ids.
- Cost. Each page reads and hashes the whole pinned prefix; §4.3 has the cost on real files.
- One actor. Cursors live in memory and are lost when the board restarts;
the refusal is
cursor-unknownwith reconcile.local-operatoris the only actor, which is not multi-actor safety (CHAT-04R). - Claude history waits for B1 and CHAT-03 (D2).
6. After review
- Commit condition (Darkwing):
serve.mjsimports../../conversation/src/reader.mjsat load, sopackages/conversation/lands in the same commit as the routes or an earlier one, after Filbert's approval. - Pushing goes through Sage with Jason's word. The live board needs a restart after the commit to serve the routes.
- The WebUI proxy allowlist for the two routes is Console work and is not in this packet.