feat(conversation): CHAT-02 read-only Pi history reader and two board routes (#1507)

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]>
This commit is contained in:
2026-09-26 16:36:20 -05:00
co-authored by Claude Opus 5.5
parent c5db8c8819
commit a5beb6d97d
22 changed files with 3247 additions and 3 deletions
@@ -0,0 +1,54 @@
# CHAT-02 board routes: Darkwing's review (#1507)
Reviewer: Darkwing, 2026-09-26, per Sage's D3. Requested by Dewey. Scope: the
two read-only routes only. Filbert reviews `packages/conversation` in full.
Candidate, base 34777c56, uncommitted. I verified both hashes:
- `packages/control-board/src/serve.mjs` afc95bdb…c540d
- `packages/control-board/tests/serve.test.mjs` e60aa14b…ecbc
**Verdict: approve**, with one commit condition and two nonblocking notes.
## What I checked
- Order. `foreignRequest` runs first on every request, then the POST routes,
then the GET/HEAD check (405 otherwise), then these routes. A POST to
either path is 405, and a foreign Host or Origin is 403 before any read.
- Query validation. `/api/conversations` refuses any parameter.
`/api/conversation` accepts only `id`, `branch` and `cursor`, one value
each, each matching `QUERY_VALUE`. That is the same pattern as
`parts.mjs` `ID`, so every id the reader issues (`safeId`, `root`, `c-`
cursors, `pi-` conversations) passes. No path comes from the request.
- Responses. JSON with `no-store` and `nosniff`, no CORS headers. A thrown
error gives a fixed 500 body and logs to stderr only.
- Refusal bodies. Every `Refusal` message in `packages/conversation/src` is
a fixed string. The one interpolated message (`denied`, safe-fs.mjs:34)
interpolates only "session root" or "session file". No path or content
reaches the client through `error`.
- Status map. It covers every code the route can reach. `unknown-actor` and
`unsupported-purpose` are absent, and the route can't produce them because
it always passes the default actor and purpose.
- Tests: `serve.test.mjs` plus `packages/conversation/tests/`, 61/61 on the
pinned files.
## Commit condition
`serve.mjs` imports `../../conversation/src/reader.mjs` at module load, and
`packages/conversation/` is untracked. Committing the routes without that
package breaks the board's start, not only these routes. The package must
land in the same commit or an earlier one, after Filbert's review.
## Notes (nonblocking)
1. **A cursor needs its branch.** The header comment says `branch` and
`cursor` are optional. But `next()` compares `branch !== record.branch`,
and every cursor record carries a string branch (`safeId` or `root`). So
`?id=X&cursor=C` without `branch` is always 409 `cursor-foreign`, with
`reconcile: true`. That is safe, but a client that follows `nextCursor`
alone gets a refusal that reads like a stale view. The test passes the
page's branch, so it doesn't show this. Either say in the comment that a
cursor call must repeat `page.branch`, or answer 400 "cursor requires
branch". I'd take the comment now and let CHAT-03's client decide.
2. **New codes fall to 422.** A code the reader adds later maps to 422
without a test failing. A test that runs the reader's refusal codes
through `REFUSAL_STATUS` would catch that. That's optional.
@@ -0,0 +1,69 @@
# CHAT-02 board routes, revision 2: Darkwing's review (#1507)
Reviewer: Darkwing, 2026-09-26, at Sage's request. Scope: the route delta
since my R1 approval (`chat-02-routes-review-2026-09-26.md`, 07b10ad1). Filbert
reviewed the backend (packet `agents/dewey/work/chat-02/BACKEND.md`, 0cf177b1).
Candidate, base 34777c56, uncommitted. I verified both hashes:
- `packages/control-board/src/serve.mjs` d62720dc…a2f3
- `packages/control-board/tests/serve.test.mjs` d38aa2b2…3f4a
**Verdict: approve.** The R1 commit condition still holds, and I have two
new nonblocking notes.
## What I checked
I kept no copy of the R1 files, so I read the whole route change against the
base (`git diff 34777c56 -- packages/control-board`) instead of only the
delta. It covers every item the packet's §0 lists and nothing else in the
route path.
- **Cursor needs its branch.** This was my R1 note 1. `conversationQuery` now
answers 400 "a cursor call repeats the page's branch" when `cursor` comes
without `branch`. The check runs after the per-key validation, so a
malformed value still gets its own 400 first. The header comment says the
same. A test covers it, and removing the line fails it.
- **Status map.** My R1 note 2. `REFUSAL_STATUS` is exported and now has 16
entries. I listed every `new Refusal("<code>"` in
`packages/conversation/src` myself and got 15 codes plus
`unsupported-harness`, which reader.mjs:352 raises by value. That matches the
map exactly. `parts.mjs` raises none. `unavailable`, which safe-fs.mjs:58
raises when a session root doesn't exist, is 404. That fits the rest of the
map, where not-found is 404. `unknown-actor` 403 and `unsupported-purpose` 422
are explicit now.
- **Order and guards** are unchanged from R1. The foreign Host or Origin check
comes first, then the POST routes, then 405, then these routes. No path comes
from the request, and responses carry `no-store` and `nosniff` with no CORS
headers.
- **Tests.** `serve.test.mjs` plus `packages/conversation/tests/` pass
67/67 on the pinned files.
- **Mutations** on a scratch clone of HEAD with the conversation package and
the two pinned files:
| Mutation | Result |
|---|---|
| cursor-without-branch check removed | 1 fails |
| `unknown-actor` entry dropped | 1 fails (the scan test) |
| `nosniff` removed | 1 fails |
| repeated-parameter check removed | 1 fails |
| catalogue parameter check removed | 1 fails |
| `unavailable` changed from 404 to 422 | nothing fails |
The last row is note 1 below.
## Commit condition (unchanged)
`serve.mjs` imports `../../conversation/src/reader.mjs` at module load, and
`packages/conversation/` is still untracked. The package must land in the
same commit as the routes or an earlier one. Otherwise the board fails to
start.
## Notes (nonblocking)
1. **The scan test checks keys, not values.** It proves every code has an
entry. No test proves `unavailable` is 404. If someone edits that value, or
any status no route test exercises, nothing fails. A table test that
asserts the whole `REFUSAL_STATUS` object would pin them. That's optional.
2. **The scan reads a fixed list of three files.** If a refusal is added to
`parts.mjs` or a new file, the scan won't see it, and that code falls to 422.
Reading every `.mjs` in `packages/conversation/src` would close the gap.
@@ -0,0 +1,218 @@
# CHAT-02 backend: review packet (#1507, row 5)
Author: Dewey, 2026-09-26. Brief: `BRIEF.md` R4 (`636b0fac…`), §2.1 and D3.
Base: `34777c56` on `refactor`. Nothing here is committed; the candidate is
the working tree, pinned by the hashes below.
Reviewers (Sage's order):
- Filbert: the code, all nine files.
- Darkwing: the two board routes, `serve.mjs` and `serve.test.mjs`.
The Console (`packages/webui`) is the next step and is not in this packet.
## 1. Candidate hashes
```
f9008c01c608ea9aacdd15459f03a4ca8f8b4fe4d5225f3f8337bc5f9282f955 packages/conversation/package.json
6f2cab5a31a26f71cf1cc00d6aed2a683e011b6cc2b52ebd4bb50b404af96d5c packages/conversation/README.md
da336ed2a2a149ff56ac12af2440a8353d4573c33e9069381be60f1a0ca70151 packages/conversation/src/parts.mjs
28e22501ab133af7dee1a38f0dc209d005ead4192b64dae6ef999ec4191db8e0 packages/conversation/src/pi.mjs
1e1db046c65292dbcfa2d59b6f0d5009dc48062cbd6a7ec123a18b6707ec8142 packages/conversation/src/reader.mjs
98835b171c1473de2ba242347b191773dfac1e50f383e43ec43b2ef4a5122a3a packages/conversation/src/safe-fs.mjs
3f89f5e7a3cdd5bb31623c799105c538fd5b5eacae368af0170e43f1130267e6 packages/conversation/tests/reader.test.mjs
afc95bdb850e4b533516984d2dd7f3ff852c90b46b384f42bc550adf9f2c540d packages/control-board/src/serve.mjs
e60aa14b77350a2807e9e9b887acb9daec1c0468d94baaf9f77548dc54fcecbc packages/control-board/tests/serve.test.mjs
```
The first seven files are new. `serve.mjs` and `serve.test.mjs` are diffs
against `34777c56`: `git diff 34777c56 -- packages/control-board`.
`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, page limits, snapshot, epoch and cursor rules, follow, the
refusal table and costs. In short:
- `rootsFromSpecs` turns the board's repository specs into roots, using
registrations only as hints. `createReader` serves `catalogue`, `open` and
`next`.
- Pages and cursors are CHAT-01 `page` and `cursor` records. Everything the
Console needs beyond CHAT-01 goes in `view`.
- The board serves `GET /api/conversations` (no parameters) and
`GET /api/conversation?id=&branch=&cursor=`. Both run after the existing
Host/Origin guard (`d1629d61`) and its GET/HEAD check.
- Responses are `application/json` with `no-store` and `nosniff`, and no CORS
headers.
- Status map:
- 404 for an unknown conversation or branch;
- 409 for cursor refusals, `source-replaced` and `incomplete-header`;
- 403 for `unsafe-path`, `foreign-project` and `unreadable`;
- 422 for the rest;
- 400 for any query parameter other than `id`, `branch` or `cursor`, a
repeated one, or a value that fails the id pattern;
- 500 with a fixed message for an exception, with details only on stderr.
## 3. Choices and deviations to review
1. **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, `unsupportedReason` and refusal. `catalogueItem` carries
binding and control fields that belong to CHAT-03. I did not want to fill
them with placeholders that look authoritative.
2. **Registrations match on `sessionsDir`, not `sessionFile`.** Registrations
have no `sessionFile` field. The brief §2.1 rule is applied to the
directory instead: seat, layout `repo`, project and `samePath(sessionsDir)`
must all match. A registration never adds a root or names a file (F9).
3. **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-harness` row when its `sessionsDir` is the standard
directory under a project root that is already approved. Its directory is
never read. Rocko (`claude-code`) is the live case (F15).
4. **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.
5. **Follow semantics.** On the last page, `follow` replaces `cursor`.
- `next` with it re-verifies the pinned prefix and takes a fresh snapshot.
- If the view's leaf is still on the new default branch, the ids of the
parts already served must be unchanged. It then returns only the new
parts.
- If the conversation went on down another branch, it returns an empty
page on the old branch and reports the new default in `view`.
- Silently switching branches would break "nothing switches silently".
6. **Missing parent next to malformed lines is bridged.** The history
continues from the previous valid entry, with an "assumed link" notice.
Pi itself would drop the history before that point. Without adjacent
malformed lines, the history stops with a notice. A follow refuses if a
line that was malformed now reads differently, which changes meaning
(F1 follow test).
7. **`unreadable` refusal.** `EACCES` or `EPERM` on a root listing or file
open becomes a per-row 403, so one unreadable file cannot fail the whole
catalogue.
8. **256 MiB file cap** (`too-large`, 422). The largest real file today is
18.6 MB.
9. **Every page re-hashes the whole pinned prefix.** This makes detection
simple and total, at a cost measured in §4.3.
10. **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`, `unknown-actor` and `unsupported-purpose`.
## 4. Evidence
All runs are on the candidate hashes above.
### 4.1 Suites and contract checks
- `node --test --test-concurrency=1 packages/conversation/tests/
packages/control-board/tests/ packages/webui/tests/ packages/seat/tests/`:
175 tests, 175 pass.
- `node docs/plans/chat-00/check.mjs`, `chat-01/check.mjs` and
`chat-01c/check.mjs` all exit 0.
### 4.2 Fixture map (brief §2.1)
| # | Test |
|---|---|
| F1 | three tests in `reader.test.mjs`: malformed notice in place (with a hostile id), bridge and missing parent, and the follow that refuses when history changes meaning |
| F2–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, pagination
at 100 parts and at the byte cap, surrogate-safe fragments, unknown and
empty and non-Pi files, `unreadable`, 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. 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. The slowest conversation took 662 ms; 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 1517 ms. Rocko
returns 422 `unsupported-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. Scripts and full output: `evidence/backend/mutate.py`,
`mutate-board.py`, `mutate-backend.txt` and `mutate-routes.txt`.
Reader: 27 of 28 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 |
| 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 |
| registration harness ignored | F15 |
| no placeholder roots | F15 |
| malformed notices dropped | F1 |
| no bridge | F1 |
| `incomplete` never set | F2, F5 |
| `next` re-reads fresh instead of pinned | F2, F5, F12, unknown/empty |
| follow skips history check | F1 follow |
| follow ignores branching | F12 |
| 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 |
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: 9 of 9 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, and a
CORS header sent.
## 5. Known limits
- **No `openat` in 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 `fstat` mismatch branch of F8** is covered by design, not by a
test. The F8 test swaps in a symlink, which `O_NOFOLLOW` refuses first. A
plain-file swap between `lstat` and `open` needs 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.
- **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-unknown` with reconcile. `local-operator` is the
only actor, which is not multi-actor safety (CHAT-04R).
- **Claude history** waits for B1 and CHAT-03 (D2).
## 6. After review
- 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.
+340
View File
@@ -0,0 +1,340 @@
# 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.mjs` and `serve.test.mjs` changed
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
1. **Branch ids are stable.**
- Roots are entries whose parent is null or absent, in file order. The
first root's line is `main`. `main` exists 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's `resetLeaf`) starts `b.<its id>` too.
- Growth never renames a branch. `page.branch` always equals the cursor's
branch, and `open({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.defaultBranch` names it; `view.branches`
flags it with `isDefault`.
- Follow rebuilds the cursor's own branch from a fresh snapshot. It
refuses `source-replaced` with reconcile if the branch is gone, is
shorter than the parts served, or the ids of those parts differ.
- Tests: F12 "two leaves" (branches `main` and `b.o4`, one branch value
across follows, the pre-growth name still opens, the `main` view 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 give `main`, every entry is
labelled `main`, and reopening with the first branch id works.
2. **The bridge is gone.**
- A missing parent stops the history with a `missing-parent` notice. 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.y` shows only the notice and `leaf`.
3. **`EACCES` and `EPERM` on a directory component become `unreadable`.**
- `lstatOrNull` maps them through `denied()`. 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 lists
`bad` in `refusedRoots` as `unreadable`. The open of an unknown id walks
both roots and returns an `unknown-conversation` refusal, not a throw.
### Filbert, small
- Redacted thinking (`redacted: true`) is `unavailable` with empty text,
whatever the `thinking` field says. Test: the mapping test.
- A relative header `cwd` is refused as `foreign-project`. Test: F10
`relative.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
1. A cursor call without `branch` is now 400 ("a cursor call repeats the
page's branch"), and the header comment says so.
2. `REFUSAL_STATUS` is exported. A new test scans the reader's three source
files for every `new Refusal("<code>"` plus `UNSUPPORTED_HARNESS` and
fails on a missing or stale entry. Its first run found `unavailable`
falling to 422; it is now 404. `unknown-actor` (403) and
`unsupported-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:
- `rootsFromSpecs` turns the board's repository specs into roots, using
registrations only as hints. `createReader` serves `catalogue`, `open` and
`next`.
- Pages and cursors are CHAT-01 `page` and `cursor` records. Everything the
Console needs beyond CHAT-01 goes in `view`.
- The board serves `GET /api/conversations` (no parameters) and
`GET /api/conversation?id=&branch=&cursor=`. Both run after the existing
Host/Origin guard (`d1629d61`) and its GET/HEAD check.
- Responses are `application/json` with `no-store` and `nosniff`, and no CORS
headers.
- Status map:
- 404 for an unknown conversation or branch, and `unavailable`;
- 409 for cursor refusals, `source-replaced` and `incomplete-header`;
- 403 for `unsafe-path`, `foreign-project`, `unreadable` and
`unknown-actor`;
- 422 for the rest, each listed in `REFUSAL_STATUS`;
- 400 for any query parameter other than `id`, `branch` or `cursor`, 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.
## 3. Choices and deviations to review
1. **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, `unsupportedReason` and refusal. `catalogueItem` carries
binding and control fields that belong to CHAT-03. I did not want to fill
them with placeholders that look authoritative.
2. **Registrations match on `sessionsDir`, not `sessionFile`.** Registrations
have no `sessionFile` field. The brief §2.1 rule is applied to the
directory instead: seat, layout `repo`, project and `samePath(sessionsDir)`
must all match. A registration never adds a root or names a file (F9).
3. **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-harness` row when its `sessionsDir` is the standard
directory under a project root that is already approved. Its directory is
never read. Rocko (`claude-code`) is the live case (F15).
4. **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.
5. **Follow stays on its branch.** On the last page, `follow` replaces
`cursor`.
- `next` with 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-replaced` with reconcile.
- If Pi's default moved to another branch, the page stays on the old
branch and `view.defaultBranch` names the new one. The Console decides
what to show; nothing switches silently.
6. **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.
7. **`unreadable` refusal.** `EACCES` or `EPERM` on a root listing, a
directory component or a file open becomes a per-row 403, so one
unreadable path cannot fail the whole catalogue.
8. **256 MiB file cap** (`too-large`, 422). The largest real file today is
18.6 MB.
9. **Every page re-hashes the whole pinned prefix.** This makes detection
simple and total, at a cost measured in §4.3.
10. **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-actor` and
`unsupported-purpose`.
## 4. Evidence
All runs are on the candidate hashes above.
### 4.1 Suites and contract checks
- In `/tmp/dewey-chat02/overlay`, a `git archive` of `1c5f6bc3` with only
the nine files overlaid: `node --test --test-concurrency=1` over 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.mjs` and
`chat-01c/check.mjs` all 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 422 `unsupported-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 `openat` in 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 `fstat` mismatch branch of F8** is covered by design, not by a
test. The F8 test swaps in a symlink, which `O_NOFOLLOW` refuses first. A
plain-file swap between `lstat` and `open` needs 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-unknown` with reconcile. `local-operator` is 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.mjs` imports
`../../conversation/src/reader.mjs` at load, so `packages/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.
@@ -0,0 +1,3 @@
rows 19 researcher:available dewey:available sage:available darkwing:available filbert:available darkwing:available filbert:available filbert:available filbert:available filbert:available filbert:available darkwing:available filbert:available dewey:available darkwing:available darkwing:available darkwing:available darkwing:available rocko:unsupported
pages 102 ms 1487
unsupported 422 {"error":"this harness has no history reader yet","refusal":{"code":"unsupported-harness","reconcile":false}}
@@ -0,0 +1,20 @@
import { startServer } from "/mnt/storage/src/mosaic-stack/packages/control-board/src/serve.mjs";
import { discoverRepoAgents } from "/mnt/storage/src/mosaic-stack/packages/control-board/src/scan.mjs";
import { homedir } from "node:os";
import { mkdtempSync } from "node:fs";
const specs = discoverRepoAgents("/mnt/storage/src/mosaic-stack");
const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir: mkdtempSync("/tmp/dewey-chat02/board-"), seatsDir: homedir() + "/.mosaic-dev/seats", isAlive: () => true, page: "x" });
const base = `http://127.0.0.1:${server.address().port}`;
const cat = await (await fetch(base + "/api/conversations")).json();
console.log("rows", cat.conversations.length, cat.conversations.map(c => `${c.seat}:${c.availability}`).join(" "));
const big = cat.conversations.filter(c => c.availability === "available");
let pages = 0, t = Date.now();
for (const c of big) {
let r = await (await fetch(`${base}/api/conversation?id=${c.conversation}`)).json();
pages++;
while (r.page?.hasMore) { r = await (await fetch(`${base}/api/conversation?id=${c.conversation}&branch=${r.page.branch}&cursor=${r.page.nextCursor}`)).json(); pages++; if (!r.ok) { console.log("refused", r); break; } }
}
console.log("pages", pages, "ms", Date.now() - t);
const u = cat.conversations.find(c => c.availability === "unsupported");
const ur = await fetch(`${base}/api/conversation?id=${u.conversation}`); console.log("unsupported", ur.status, JSON.stringify(await ur.json()));
server.close();
@@ -0,0 +1,39 @@
CAUGHT no O_NOFOLLOW + no lstat symlink -> F7: a symlinked file and a symlinked directory component are refused, ; F8: a file swapped for a symlink after the catalogue is refused
CAUGHT dir components unchecked -> F7: a symlinked file and a symlinked directory component are refused,
CAUGHT listing follows symlinks -> F7: a symlinked file and a symlinked directory component are refused, ; F8: a file swapped for a symlink after the catalogue is refused
CAUGHT no prefix digest check -> F4: a same-inode rewrite of the prefix refuses old cursors with reconc
CAUGHT no dev/ino check -> F3: a replaced file
MISSED no shorter check ->
CAUGHT cwd always in project -> F10: a header cwd naming another project is refused
CAUGHT relative cwd accepted -> F10: a header cwd naming another project is refused
CAUGHT no char cap -> F14: long strings split into fragments and parts, reassemble exactly, ; every page and cursor is a valid CHAT-01 record
CAUGHT no page byte cap -> F14: long strings split into fragments and parts, reassemble exactly,
CAUGHT no block cap -> F14: long strings split into fragments and parts, reassemble exactly, ; every page and cursor is a valid CHAT-01 record
CAUGHT no foreign check -> F6: unknown, foreign and expired cursors refuse and leave the cursor u
CAUGHT no branch binding -> F6: unknown, foreign and expired cursors refuse and leave the cursor u
CAUGHT no expiry -> F6: unknown, foreign and expired cursors refuse and leave the cursor u
CAUGHT no parentSession notice -> F11: parentSession renders with a marker and the parent is never opene
CAUGHT branch param ignored -> F12: a second root; F12: two leaves: the default leaf is shown and the other branch reads ; F1: a missing parent stops the history with a notice that names the un
CAUGHT unknown branch served -> F12: two leaves: the default leaf is shown and the other branch reads
CAUGHT registration harness ignored -> F15: a Claude seat is an unsupported-harness placeholder whose directo
CAUGHT no placeholder roots -> F15: a Claude seat is an unsupported-harness placeholder whose directo
CAUGHT malformed notices dropped -> F1: a file whose entries are all unreadable shows a notice per line; F1: a follow stays on its branch when the next entry's parent is unrea; F1: a malformed line is an unavailable part at its position, and readi; F1: a missing parent stops the history with a notice that names the un
CAUGHT bridge restored -> F1: a follow stays on its branch when the next entry's parent is unrea; F1: a missing parent stops the history with a notice that names the un; F1: an unreadable fork is never merged into another branch's history
CAUGHT missing-parent lines not named -> F1: a follow stays on its branch when the next entry's parent is unrea; F1: a missing parent stops the history with a notice that names the un; F1: an unreadable fork is never merged into another branch's history
CAUGHT trailing malformed only on default -> F1: a follow stays on its branch when the next entry's parent is unrea; F1: a missing parent stops the history with a notice that names the un
CAUGHT branch named by leaf -> F12: a follow refuses when an appended duplicate id changes the branch; F12: a second root; F12: two leaves: the default leaf is shown and the other branch reads ; F1: a follow stays on its branch when the next entry's parent is unrea; F1: a missing parent stops the history with a notice that names
CAUGHT later child continues branch -> F12: two leaves: the default leaf is shown and the other branch reads
CAUGHT first root not main -> F12: a follow refuses when an appended duplicate id changes the branch; F12: a second root; F12: two leaves: the default leaf is shown and the other branch reads ; F1: a follow stays on its branch when the next entry's parent is unrea; F1: a missing parent stops the history with a notice that names
CAUGHT no empty main -> F1: a file whose entries are all unreadable shows a notice per line; unknown conversations, empty files and non-Pi files refuse
CAUGHT redacted thinking shown -> native entries map to blocks: tools, thinking, bash, notices, ids that
CAUGHT EACCES on a component rethrown -> a seat directory without search permission refuses that root, not the
CAUGHT incomplete never set -> F2: a truncated trailing line marks the view incomplete, not an error; F5: growth between pages keeps the epoch and the page stops at the pin
CAUGHT next re-reads fresh -> F12: a second root; F12: two leaves: the default leaf is shown and the other branch reads ; F1: a follow stays on its branch when the next entry's parent is unrea; F2: a truncated trailing line marks the view incomplete, not an error; F5: growth between pages keeps the epoch and the page stops at th
CAUGHT follow skips history check -> F12: a follow refuses when an appended duplicate id changes the branch
CAUGHT follow skips the id digest -> F12: a follow refuses when an appended duplicate id changes the branch
CAUGHT follow reads the default branch -> F12: two leaves: the default leaf is shown and the other branch reads ; F1: a follow stays on its branch when the next entry's parent is unrea
CAUGHT catalogue writes a file -> F10: a header cwd naming another project is refused; F15: a Claude seat is an unsupported-harness placeholder whose directo; F1: a malformed line is an unavailable part at its position, and readi; F7: a symlinked file and a symlinked directory component are refused, ; F8: a file swapped for a symlin
CAUGHT thinking visibility wrong -> native entries map to blocks: tools, thinking, bash, notices, ids that
CAUGHT raw tool ids -> every page and cursor is a valid CHAT-01 record; native entries map to blocks: tools, thinking, bash, notices, ids that
CAUGHT id namespaces dropped -> F1: a malformed line is an unavailable part at its position, and readi
CAUGHT surrogate split -> fragments never cut a surrogate pair and keep an empty string
@@ -0,0 +1,30 @@
import subprocess, os
S="/tmp/dewey-chat02/scratch-board"
f=os.path.join(S,"packages/control-board/src/serve.mjs")
orig=open(f).read()
M=[
("conversation routes before the guard",[(""" const refused = foreignRequest(req);""",""" { const u0 = new URL(req.url, "http://localhost"); if (u0.pathname.startsWith("/api/conversation")) { let o; try { o = conversationResponse(reader, u0); } catch { o = { status: 500, body: {} }; } return sendConversationJson(res, o.status, o.body); } }
const refused = foreignRequest(req);""")]),
("no nosniff",[(', "x-content-type-options": "nosniff"','')]),
("all refusals 422",[("status: REFUSAL_STATUS[out.refusal.code] ?? 422","status: 422")]),
("no query validation",[(" if (values.length !== 1 || !QUERY_VALUE.test(values[0])) return { error: `invalid ${key}` };\n","")]),
("unknown params allowed",[(""" if (!["id", "branch", "cursor"].includes(key)) return { error: `unknown parameter: ${key}` };\n""","")]),
("reconcile dropped",[("refusal: { code: out.refusal.code, reconcile: out.refusal.reconcile }","refusal: { code: out.refusal.code }")]),
("cursor ignored",[(" const out = query.cursor\n"," const out = false\n")]),
("registrations ignored",[("rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations)","rootsFromSpecs(specs, [])")]),
("cursor without branch served",[(" if (out.cursor && !out.branch) return"," if (false) return")]),
("unavailable falls to 422",[(" unavailable: 404,\n","")]),
("CORS header sent",[('"x-content-type-options": "nosniff" });','"x-content-type-options": "nosniff", "access-control-allow-origin": "*" });')]),
]
for name,reps in M:
src=orig
ok=True
for a,b in reps:
if a not in src: print("NOT APPLIED",name); ok=False; break
src=src.replace(a,b,1)
if not ok: continue
open(f,"w").write(src)
r=subprocess.run(["node","--test","--test-concurrency=1","tests/serve.test.mjs"],cwd=os.path.join(S,"packages/control-board"),capture_output=True,text=True,timeout=600)
fails=sorted(set(l.strip()[2:].split(" (")[0][:60] for l in r.stdout.splitlines() if l.lstrip().startswith("✖") and "failing tests" not in l))
print(("CAUGHT " if r.returncode else "MISSED ")+name+" -> "+"; ".join(fails))
open(f,"w").write(orig)
@@ -0,0 +1,11 @@
CAUGHT conversation routes before the guard -> conversation routes
CAUGHT no nosniff -> conversation routes: catalogue, first page, next page and fo
CAUGHT all refusals 422 -> conversation routes: catalogue, first page, next page and fo
CAUGHT no query validation -> conversation routes: catalogue, first page, next page and fo
CAUGHT unknown params allowed -> conversation routes: catalogue, first page, next page and fo
CAUGHT reconcile dropped -> conversation routes: catalogue, first page, next page and fo
CAUGHT cursor ignored -> conversation routes: catalogue, first page, next page and fo
CAUGHT registrations ignored -> conversation routes: catalogue, first page, next page and fo
CAUGHT cursor without branch served -> conversation routes: catalogue, first page, next page and fo
CAUGHT unavailable falls to 422 -> every refusal code the reader can raise has an HTTP status
CAUGHT CORS header sent -> conversation routes: catalogue, first page, next page and fo
@@ -0,0 +1,59 @@
import subprocess, sys, shutil, os
S="/tmp/dewey-chat02/scratch/packages/conversation"
M=[
("no O_NOFOLLOW + no lstat symlink","src/safe-fs.mjs",[("constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK","constants.O_RDONLY"),(' if (before.isSymbolicLink()) throw new Refusal("unsafe-path", "session file is a symlink");\n',''),(' if (!before.isFile()) throw',' if (false) throw'),("if (!st.isFile() || st.dev !== before.dev","if (st.dev !== before.dev")]),
("dir components unchecked","src/safe-fs.mjs",[(' if (st.isSymbolicLink()) throw new Refusal("unsafe-path", "session root contains a symlink");\n',''),(' if (!st.isDirectory()) throw',' if (false) throw')]),
("listing follows symlinks","src/safe-fs.mjs",[(' else if (dirent.isSymbolicLink()) refused.push({ name: dirent.name, code: "unsafe-path" });\n','')]),
("no prefix digest check","src/reader.mjs",[('if (pinned.digest !== state.digest) throw','if (false) throw')]),
("no dev/ino check","src/reader.mjs",[('if (pinned.file.dev !== state.dev || pinned.file.ino !== state.ino) throw','if (false) throw'),('if (fresh.file.dev !== state.dev || fresh.file.ino !== state.ino || fresh.length < state.length) throw','if (fresh.length < state.length) throw')]),
("no shorter check","src/reader.mjs",[('if (pinned.shorter) throw','if (false) throw')]),
("cwd always in project","src/reader.mjs",[(' if (typeof cwd !== "string" || !isAbsolute(cwd)) return false;',' return true;')]),
("relative cwd accepted","src/reader.mjs",[(' if (typeof cwd !== "string" || !isAbsolute(cwd)) return false;',' if (typeof cwd !== "string" || !cwd) return false;')]),
("no char cap","src/parts.mjs",[("chars + 1 > LIMITS.chars ||","false ||")]),
("no page byte cap","src/parts.mjs",[("if (bytes + add > LIMITS.pageBytes) break;","")]),
("no block cap","src/parts.mjs",[("current.length === LIMITS.blocks ||","false ||")]),
("no foreign check","src/reader.mjs",[("if (actor !== record.actor || purpose !== record.purpose || conversation !== record.conversation || branch !== record.branch) {","if (false) {")]),
("no branch binding","src/reader.mjs",[("|| branch !== record.branch) {",") {")]),
("no expiry","src/reader.mjs",[("if (Date.parse(record.expiresAt) <= now()) {","if (false) {")]),
("no parentSession notice","src/pi.mjs",[(" if (parsed.header.parentSession !== undefined && parsed.header.parentSession !== null) {\n units.push"," if (false) {\n units.push")]),
("branch param ignored","src/reader.mjs",[("const built = build(snap, found.root, branch ?? null, conversation);","const built = build(snap, found.root, null, conversation);")]),
("unknown branch served","src/reader.mjs",[("if (!parsed.branches.has(branch)) return null;","if (false) return null;")]),
("registration harness ignored","src/reader.mjs",[("unsupportedReason: harness !== null && harness !== HISTORY ? UNSUPPORTED_HARNESS : null,","unsupportedReason: null,")]),
("no placeholder roots","src/reader.mjs",[(" if (!reg || reg.layout !== \"repo\""," if (true || reg.layout !== \"repo\"")]),
("malformed notices dropped","src/pi.mjs",[('out.push({ notice: "malformed", lines: [pending[mi++].line] });','mi++;')]),
("bridge restored","src/pi.mjs",[(' path.push({ notice: "missing-parent", lines: lost });\n break;',' const prev = r.index > 0 ? parsed.byId.get(parsed.entries[r.index - 1].entry.id) : null;\n if (lost.length && prev) { path.push({ notice: "missing-parent", lines: lost }); r = prev; continue; }\n path.push({ notice: "missing-parent", lines: lost });\n break;')]),
("missing-parent lines not named","src/pi.mjs",[("const lost = parsed.malformed.filter((m) => m.after === r.index - 1).map((m) => m.line);","const lost = [];")]),
("trailing malformed only on default","src/pi.mjs",[(" flushBefore(Infinity);\n return out;"," if (leaf === parsed.defaultLeaf) flushBefore(Infinity);\n return out;")]),
("branch named by leaf","src/pi.mjs",[(" const branchOf = (leaf) => {"," const branchOf = (leaf) => branchName(leaf);\n const unused = (leaf) => {")]),
("later child continues branch","src/pi.mjs",[("if (children.get(p.entry.id)[0] !== r) return branchName(r);","if (children.get(p.entry.id).at(-1) !== r) return branchName(r);")]),
("first root not main","src/pi.mjs",[("return r === firstRoot ? MAIN : branchName(r);","return branchName(r);")]),
("no empty main","src/pi.mjs",[(" if (!branches.size) branches.set(MAIN, null);\n","")]),
("redacted thinking shown","src/pi.mjs",[('const t = b.redacted === true ? "" : typeof b.thinking','const t = typeof b.thinking')]),
("EACCES on a component rethrown","src/safe-fs.mjs",[('throw denied(err, "a session path component");','throw err;')]),
("incomplete never set","src/reader.mjs",[("incomplete: buf.length > length };","incomplete: false };")]),
("next re-reads fresh","src/reader.mjs",[("const pinned = snapshot(state.root, state.name, state.length);","const pinned = { ...snapshot(state.root, state.name), length: state.length };")]),
("follow skips history check","src/reader.mjs",[("if (!built || built.entries.length < state.offset || idsDigest(built.entries, state.offset) !== state.prefix) {","if (!built) {")]),
("follow skips the id digest","src/reader.mjs",[("|| idsDigest(built.entries, state.offset) !== state.prefix) {",") {")]),
("follow reads the default branch","src/reader.mjs",[("const built = build(fresh, state.root, state.branch, conversation);","const built = build(fresh, state.root, null, conversation);")]),
("catalogue writes a file","src/reader.mjs",[(" function catalogue() {"," function catalogue() {\n for (const r of listRoots()) { try { require_(r); } catch {} }")]),
("thinking visibility wrong","src/pi.mjs",[('visibility: t ? "permitted-visible" : "unavailable"','visibility: "permitted-visible"')]),
("raw tool ids","src/pi.mjs",[("call: safeId(b.id), name: safeId(b.name)","call: String(b.id), name: String(b.name)")]),
("id namespaces dropped","src/pi.mjs",[("id: `n.${e.id}`","id: e.id")]),
("surrogate split","src/parts.mjs",[("const width = cp > 0xffff ? 2 : 1;","const width = 1;")]),
]
orig={}
for f in ["src/safe-fs.mjs","src/reader.mjs","src/parts.mjs","src/pi.mjs"]:
orig[f]=open(os.path.join(S,f)).read()
for name,f,reps in M:
src=orig[f]
for a,b in reps:
if a not in src: print("NOT APPLIED",name,repr(a[:60])); break
src=src.replace(a,b)
else:
if name=="catalogue writes a file":
src=src.replace("require_(r);",'(await_import => 0)(); (require_fs => 0)(); import_fs.writeFileSync(r.dir + "/x.tmp", "")').replace('import { createHash, randomBytes } from "node:crypto";','import { createHash, randomBytes } from "node:crypto";\nimport * as import_fs from "node:fs";')
open(os.path.join(S,f),"w").write(src)
r=subprocess.run(["node","--test","tests/"],cwd=S,capture_output=True,text=True,timeout=600)
fails=sorted(set(l.strip()[2:].split(" (")[0] for l in r.stdout.splitlines() if l.lstrip().startswith("✖") and "failing tests" not in l))
print(("CAUGHT " if r.returncode else "MISSED ")+name+" -> "+"; ".join(x[:70] for x in fails)[:300])
open(os.path.join(S,f),"w").write(orig[f])
@@ -0,0 +1,2 @@
{ convs: 18, pages: 102, entries: 8693, refusals: {}, maxMs: 798 }
catalogue 19 rows in 75 ms, refusedRoots []; 186 sampled pages and cursors: 0 schema-invalid; every page branch: main (102)
@@ -0,0 +1,37 @@
import { discoverRepoAgents } from "/mnt/storage/src/mosaic-stack/packages/control-board/src/scan.mjs";
import { rootsFromSpecs, createReader } from "/mnt/storage/src/mosaic-stack/packages/conversation/src/reader.mjs";
import { readRegistration, LAYOUTS } from "/mnt/storage/src/mosaic-stack/packages/seat/src/seat.mjs";
import { readdirSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
const seats = homedir() + "/.mosaic-dev/seats";
const regs = [];
import { loadRegistrations } from "/mnt/storage/src/mosaic-stack/packages/control-board/src/scan.mjs"; regs.push(...loadRegistrations(seats).registrations);
const roots = rootsFromSpecs(discoverRepoAgents("/mnt/storage/src/mosaic-stack"), regs);
console.log(roots.map(r => `${r.seat} ${r.harness} ${r.unsupportedReason} ${r.engineStartedAt}`).join("\n"));
const reader = createReader({ roots });
let t = Date.now();
const cat = reader.catalogue();
console.log("catalogue", cat.conversations.length, "ms", Date.now() - t, "refusedRoots", JSON.stringify(cat.refusedRoots));
const byAvail = {}; for (const c of cat.conversations) byAvail[c.availability + ":" + c.refusal] = (byAvail[c.availability + ":" + c.refusal] ?? 0) + 1;
console.log(byAvail);
console.log(cat.conversations.slice(0, 3));
const pages = [];
let stats = { convs: 0, pages: 0, entries: 0, refusals: {} , maxMs: 0};
for (const c of cat.conversations.filter(c => c.availability === "available")) {
t = Date.now();
let r = reader.open({ conversation: c.conversation });
if (!r.ok) { stats.refusals[r.refusal.code] = (stats.refusals[r.refusal.code] ?? 0) + 1; continue; }
stats.convs++;
let n = 0;
while (true) {
stats.pages++; stats.entries += r.page.entries.length; n++;
if (pages.length < 400) pages.push(r.page);
if (r.cursor) pages.length < 400 && pages.push(r.cursor);
if (!r.page.hasMore) break;
r = reader.next({ cursor: r.page.nextCursor, conversation: c.conversation, branch: r.page.branch });
if (!r.ok) { console.log("next refused", r.refusal); break; }
}
stats.maxMs = Math.max(stats.maxMs, Date.now() - t);
}
console.log(stats);
writeFileSync("/tmp/dewey-chat02/pages.json", JSON.stringify(pages));
@@ -0,0 +1,206 @@
# 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.html` and
`packages/ledger/src/ledger.mjs`, and four webui browser tests fail there.
To test the candidate alone, I ran a detached worktree at HEAD `1c5f6bc3`
with only the nine pinned files overlaid (pins re-verified in place). The
result: conversation, control-board, webui and seat, 175/175. `chat-00`,
`chat-01` and `chat-01c` `check.mjs` all 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;
- `retainedTail` is documented in `docs/session-format.md` 245.
The parser matches all four.
- **Safe open.** It opens with `O_NOFOLLOW`, compares (dev, ino) after the
`fstat`, 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>/sessions` shape. The listing refuses
symlinks and odd names without opening them.
- **Accepted choices, packet §3:**
- item 1, the summary row;
- item 2, `sessionsDir` matching, which is stricter than the brief's
`sessionFile`;
- 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
1. **A branch's id changes every time the conversation grows.**
- The branch id is the leaf entry's id (`reader.mjs` 270). 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 append `c→d`.
- The open page has `branch: "b"`.
- The follow, sent with `branch: "b"` on a cursor bound to `b`, returns
a page with `branch: "d"`, and its entries carry `d`.
- `open({branch: "b"})` then refuses `unknown-branch` (404, reconcile).
- The F12 test asserts this behaviour (`g.page.branch === "o5"` after
following `o4`).
- Effects:
- One view's entries carry different `branch` values.
- 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.
- 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.branches` lists each branch's current leaf and last activity.
`page.branch` always equals the cursor's branch.
- Tests:
- a follow keeps the branch id;
- a pre-growth id still opens;
- entries across follows share one `branch` value;
- F12 with the new ids;
- a two-root file.
2. **The "assumed link" bridge merges branches.**
- `branchPath` (`pi.mjs` 94–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 held
`X→a` (a fork from `a`), then `y→X`.
- `view.branches` lists `b` and `y` as separate branches.
- The default page for `y` still shows `root`, then `ON THE OTHER
BRANCH` (b's text), then the notice, then `leaf`. 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.
3. **A directory component without search permission fails the whole
catalogue.**
- `lstatOrNull` (`safe-fs.mjs` 38–45) rethrows `EACCES`, so `checkRoot`
throws a plain error instead of a refusal. `catalogue()` rethrows it, and
the route returns 500.
- Scratch file: two seats, with `.pi/state/bad` at mode 000.
- `catalogue()` throws `EACCES` on lstat of `bad/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`/`EPERM` in `lstatOrNull` through `denied()`.
- Test: a seat directory at mode 000 beside a readable one. The catalogue
lists the readable root and one `unreadable` entry in `refusedRoots`.
Opens in the readable root still work in either root order.
## 3. Small, same revision
1. **Redacted thinking is marked visible.** Pi stores Anthropic
`redacted_thinking` as `{type: "thinking", thinking: "[Reasoning
redacted]", redacted: true}` (`pi-ai` `anthropic-messages.js` 445–451). The
parser gives it `permitted-visible` with that placeholder. CHAT-01 says
unavailable reasoning has empty text. Map `redacted === true` to
`unavailable` with `""`, and never read `thinkingSignature`. There are no
live cases today.
2. **A relative header `cwd` resolves against the board's working
directory.** `cwdInProject` calls `resolve(cwd)`. With `cwd: "."` and the
board started from the checkout, the file passes. Pi writes absolute
paths. Refuse a non-absolute `cwd` as `foreign-project`, and add a test.
3. **A file whose entries are all malformed shows no notices.** With no leaf,
`build` uses an empty path (`reader.mjs` 272), so `view.unreadableLines`
is N and the page has no parts. Emit the malformed notices when there is no
leaf.
## 4. Not findings
- The F8 `fstat` mismatch 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 `1c5f6bc3` with only the nine files
overlaid: 181/181. `chat-00`, `chat-01` and `chat-01c` exit 0.
- **My reproductions, rerun on the candidate:**
- `branch.mjs`: open and follow both give `main`, and the pre-growth
reopen works.
- `bridge.mjs`: `b.y` shows the missing-parent notice (naming line 4) and
`leaf` only. `main` stays separate.
- `perm.mjs`: the catalogue returns the readable row, with `bad` in
`refusedRoots` as `unreadable`. An unknown id refuses instead of throwing.
- **A new probe (`fork.mjs`):** a linear `main`, then a fork from its middle,
then growth back on `main`, 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.defaultBranch` tracked Pi's last entry.
- `open` by name still worked afterwards.
- **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 `unavailable` with `""`; a relative
`cwd` is `foreign-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.