packages/ledger/src/t3.mjs reads ~/.t3/userdata/state.sqlite read-only, in one transaction. It maps each thread to a seat by title and checks self-addressed headers. Unmatched threads go in a t3:unmapped row. A missing or locked database exits 1 and names --no-t3. Gate F is on by default (lead decision 12). The 6a uppercase-class fix rides here. Separate item: the Pi session reader splits lines only on \n, so a raw U+2028 or U+2029 in a string no longer splits a record. Node 26.8.1's readline split there, and the live ledger refused on HEAD. Darkwing built to brief R3 (f3c05c1b); manifest ba73a163. Filbert approved the build (e47ec6da) and the U+2028 fix as its own item; brief review be1aa414. On an index export: the eight suites 24/90/43/17/14/15/63/18, ledger 47/47. Four nonblocking notes go to a small follow-up. Co-Authored-By: Claude Opus 5.5 <[email protected]>
8.4 KiB
Gate F build: the ledger's T3 source (#1506), candidate for review
Darkwing built this on 2026-09-26 from the approved brief R3,
docs/plans/2026-09-26_ledger-t3-source.md (sha256 f3c05c1b, committed in
ffc22c04). Sage gave the go once Filbert confirmed R3. Filbert reviews the
code; Sage commits after the suites. Base is HEAD 1c5f6bc3. Nothing is
committed or pushed.
Files
build-manifest.sha256 pins the five files, and build.patch is the diff
against 1c5f6bc3 with t3.mjs included as a new file.
packages/ledger/src/t3.mjs(new).readT3(root, range, {dbPath, isDefault}): path checks, one read transaction, schema check, project, title mapping, header cross-check, counts, mentions, diagnostic.packages/ledger/src/ledger.mjs. The class fix,t3Header(),readSeats(),mergeSources(), thepiandt3keys in the report, the text line for--no-t3or a non-default path, and the U+2028 fix below.packages/ledger/src/cli.mjs.--no-t3and--t3-db PATH, which refuse each other; the usage line.packages/ledger/tests/ledger.test.mjs. HOME at both spawn sites, the empty default database, 25 new tests.packages/ledger/README.md. A new "T3 source" section.
The commit should also carry Filbert's updated review,
agents/filbert/work/ledger-t3-source-review-2026-09-26.md (be1aa414), and
this directory's new files.
Beyond the brief: the Pi reader split valid lines
The brief's live read has to exit 0. It didn't, and T3 wasn't the cause. HEAD
refuses the live checkout the same way:
Malformed session JSON: filbert/2026-09-12T16-38-58-597Z_01a0967c-….jsonl:611.
That line parses. It holds a raw U+2028 inside a JSON string, which JSON
allows and JSON.stringify writes unescaped. Node 26.8.1's readline ends a
line at U+2028 too, so it cut the record in two (733 lines by readline, 732
by \n). The reader parses every line before it checks the range, so on
Node 26.8.1 every live run refuses, whatever the dates. The file was last
written 2026-09-14. I haven't checked which Node version first split there.
The fix replaces readline with a small splitter that ends lines at \n
only. It sits in ledger.mjs, which this build already changes, and it
blocked acceptance, so I made it here instead of filing it. A new test writes a
Pi log with a raw U+2028 and CRLF endings; it fails with readline and passes
with the splitter. Please review it as its own item.
Choices the brief left open
- Imported threads are excluded by the
import:prefix alone. Live, all 1678historyImportevents sit inimport:streams, so the two rules agree today. The events table stays optional, so the exclusion doesn't depend on it. - The header cross-check runs over every user message in a counted thread, in range or not. The title mapping is current state, so a conflict in old history still misassigns counts for any range that includes it.
- Validation (role, text,
created_at) also covers every message in a counted thread, assistant rows included, and not only rows in range. - The diagnostic is in range:
humanSentThroughApiandhumanWithoutEvent. One unparseable event, or an event with no stringmessageId, makes bothunknown, the same as a missing table (F5). - Project and thread matching compare
workspace_rootin JavaScript, so a declared collation on the column can't loosen byte-for-byte equality. - JSON adds top-level
pi(Pi rows) andt3(read flag, database, seats with threads, unmapped, excluded, diagnostic).seatsandtotalskeep their shape, so existing consumers and tests are unchanged.t3.seatslists a seat whenever it has a mapped thread, even with zero counts in range. - The unmapped row comes last in
seats, and only when it has counts.
Evidence
- Ledger tests:
node --test packages/ledger/tests/, 47/47 (ledger 44, of which 25 are new, and gitea helper 3). The busy-timeout test takes about 5.4 s. - Class fix against HEAD. HEAD's
messageKind(fromgit show 1c5f6bc3:packages/ledger/src/ledger.mjs) calls a T3 header withclass=REVIEW-REQUEST, a tmux preamble withclass=DECISIONand a T3 header withclass=Actionableall human. The build calls them agent. The existing test assertingclass=Actionableis human now asserts agent. - Mutations, each on a scratch copy of the package. Three
gitea-helpertests fail in every scratch copy because they need the repository'sscripts/, so the counts below leave them out.- Classes back to
[a-z-]+: 6 fail. - No header cross-check: 2 fail.
- No symlink refusal: 4 fail.
- Busy timeout 0: 1 fails.
- Two projects allowed: 1 fails.
- Deleted threads kept, imported threads kept, or range filter removed: 4 fail each.
- No role check: 1 fails.
- No diagnostic table check: 1 fails.
readlinerestored: 1 fails.
- Classes back to
- Two mutations pass, and I'm naming them rather than hiding them:
- Removing
mode=rochanges nothing, becausereadOnly: truealready opens read-only. Both stay, as the brief says. - Removing
BEGINfails 19 tests, but only becauseCOMMITthen has no transaction. No test proves that the queries share one snapshot.
- Removing
- Eight suites on a local clone of
1c5f6bc3with the five files: config 24, task 90, foundation 43, conductor 17, release 14, auth 15, discord 63, extension-package 18. The first task run showed 89/1, and I didn't capture the failing line. Three more task runs passed 90/90. I count it as a flake I can't name, not as green on the first try. - Union (control-board, webui, seat, mosaic, ledger, discord) on the same clone: 434/434 three times, 23 to 24 s each. No fake pi left running.
- No test opens the real
~/.t3. Every CLI spawn setsHOMEto a temp directory, and no test callsreadT3in process. After the runs, noledger-*temp directories remained.
Live read
node packages/ledger/src/cli.mjs --since 2026-09-01 --until 2026-09-26 --no-issues, exit 0 three times, no header conflict. The table is the run at
2026-09-26T21:31:02Z.
| Seat | T3 threads | T3 board / agent / human | Pi board / agent / human |
|---|---|---|---|
| darkwing | Darkwing; Darkwing in Claude (archived) | 0 / 19 / 28 | 14 / 109 / 141 |
| dewey | Dewey; Dewey in Claude | 0 / 17 / 7 | 7 / 57 / 16 |
| filbert | Filbert | 0 / 25 / 1 | 5 / 92 / 9 |
| rocko | Rocko | 0 / 20 / 1 | none |
| sage | Sage | 0 / 52 / 10 | 0 / 193 / 72 |
| researcher | none | none | 3 / 1 / 1 |
| t3:unmapped | Discord Bot | 0 / 0 / 68 | none |
This matches the brief, allowing for messages sent since 20:54Z. It maps the
same seven threads. Discord Bot has 68 human: 54 without a header and the 14
free-text headers. The diagnostic reads exactly those 14
(humanSentThroughApi: 14, humanWithoutEvent: 0). T3 agent messages total
133, against the brief's 96 API headers (80 plus the 16 uppercase ones) at
20:54Z. Two imported threads are excluded, and this project has no deleted
threads.
Not covered
- Snapshot isolation across the queries (see the
BEGINmutation above). - A seat directory named
t3:unmappedwould share the unmapped row. Directory names that contain a colon aren't used inagents/. - The live read's effect on the main database file can't be checked while T3 writes to it. The stopped and writer-attached WAL tests check it on fixtures.
Review and correction
Filbert approved manifest ba73a163 and the U+2028 fix as its own item:
agents/filbert/work/ledger-t3-build-review-2026-09-26.md, sha256 e47ec6da.
Correction to "Beyond the brief" above. Line 611 holds a raw U+2028 and a raw
U+2029, and readline ends a line at each. The file has 731 lines by \n
(wc -l agrees), and readline makes 733. I wrote 732 because I counted the
empty string after the final newline. The splitter already ends lines at \n
only, so the fix covers both characters. The test and the README name only
U+2028.
Filbert's nonblocking notes, for a follow-up after the Gate F commit, since changing the pinned files now would void the approval:
- Add a U+2029 to the splitter test and the README line.
- Two diagnostic mutations survive:
humanWithoutEventhardcoded to 0, and an unparseable event skipped instead of making the diagnosticunknown. Each needs one fixture message. readT3's catch reports any error that isn't aSourceErroras a SQLite read failure. It still exits 1, but a bug would read as a database problem. Rethrow errors that carry noerrcode.- Snapshot isolation stays untested, as recorded above.