# 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()`, the `pi` and `t3` keys in the report, the text line for `--no-t3` or a non-default path, and the U+2028 fix below. - `packages/ledger/src/cli.mjs`. `--no-t3` and `--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 1678 `historyImport` events sit in `import:` 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: `humanSentThroughApi` and `humanWithoutEvent`. One unparseable event, or an event with no string `messageId`, makes both `unknown`, the same as a missing table (F5). - Project and thread matching compare `workspace_root` in JavaScript, so a declared collation on the column can't loosen byte-for-byte equality. - JSON adds top-level `pi` (Pi rows) and `t3` (read flag, database, seats with threads, unmapped, excluded, diagnostic). `seats` and `totals` keep their shape, so existing consumers and tests are unchanged. `t3.seats` lists 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` (from `git show 1c5f6bc3:packages/ledger/src/ledger.mjs`) calls a T3 header with `class=REVIEW-REQUEST`, a tmux preamble with `class=DECISION` and a T3 header with `class=Actionable` all human. The build calls them agent. The existing test asserting `class=Actionable` is human now asserts agent. - Mutations, each on a scratch copy of the package. Three `gitea-helper` tests fail in every scratch copy because they need the repository's `scripts/`, 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. - `readline` restored: 1 fails. - Two mutations pass, and I'm naming them rather than hiding them: - Removing `mode=ro` changes nothing, because `readOnly: true` already opens read-only. Both stay, as the brief says. - Removing `BEGIN` fails 19 tests, but only because `COMMIT` then has no transaction. No test proves that the queries share one snapshot. - Eight suites on a local clone of 1c5f6bc3 with 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 sets `HOME` to a temp directory, and no test calls `readT3` in process. After the runs, no `ledger-*` 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 `BEGIN` mutation above). - A seat directory named `t3:unmapped` would share the unmapped row. Directory names that contain a colon aren't used in `agents/`. - 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: 1. Add a U+2029 to the splitter test and the README line. 2. Two diagnostic mutations survive: `humanWithoutEvent` hardcoded to 0, and an unparseable event skipped instead of making the diagnostic `unknown`. Each needs one fixture message. 3. `readT3`'s catch reports any error that isn't a `SourceError` as a SQLite read failure. It still exits 1, but a bug would read as a database problem. Rethrow errors that carry no `errcode`. 4. Snapshot isolation stays untested, as recorded above.