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:
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user