docs(ledger): Gate F T3 thread source brief R2, Filbert approved (#1506)

Brief e8300cb6 (Darkwing), review bb02d8d3 (Filbert, approve with three
nits), R1 record and R1-to-R2 diff. Lead rulings in item 14.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 16:04:56 -05:00
co-authored by Claude Opus 5.5
parent 34777c56bc
commit ffc22c04c6
8 changed files with 1635 additions and 0 deletions
@@ -0,0 +1,5 @@
08959a05574264e4f8243a90af94746e73a2fde3706f22e38f4ff1105b7a45a8 agents/darkwing/work/ledger-t3-source/r1.md
e8300cb6abea70819aba7cf10040d19b4d6019b5663c37203209537a5f10ee62 agents/darkwing/work/ledger-t3-source/r2.md
f3c05c1b4d28a419ab817621e15708b147f71dff588664980e22696eaafbc342 docs/plans/2026-09-26_ledger-t3-source.md
aa4740ae5d045aa12971af5de36a5107805bd31da839aae4e4d398a818d471fc agents/darkwing/work/ledger-t3-source/r1-to-r2.diff
4128375121673e0a49ef6789390503ff7395ba59f87bfc450764ea56b536c5b2 agents/darkwing/work/ledger-t3-source/r2-to-r3.diff
@@ -0,0 +1,419 @@
--- r1.md
+++ docs/plans/2026-09-26_ledger-t3-source.md
@@ -1,8 +1,11 @@
# Ledger: a read-only T3 thread source for Table 2 (Gate F brief)
-Brief only, no code. Darkwing wrote it on 2026-09-26 at Sage's request. Filbert
-reviews it, and Jason sees it on the decision sheet before anyone builds it.
-Issue #1506.
+Brief only, no code. Darkwing wrote it on 2026-09-26 at Sage's request, issue
+#1506. R1 (sha256 08959a05) went to Filbert, whose review asked for
+revisions: `agents/filbert/work/ledger-t3-source-review-2026-09-26.md`, sha256
+19dda29a. This is R2. It takes every finding, and it records Sage's rulings
+on the three open questions. Section 1 has one measurement that differs from
+the review.
## Why
@@ -18,8 +21,8 @@
## Where T3 keeps messages
T3 keeps its state in one SQLite database, `~/.t3/userdata/state.sqlite`, in
-WAL mode (`state.sqlite-wal` and `state.sqlite-shm` sit beside it). Three
-projection tables are enough:
+WAL mode (`state.sqlite-wal` and `state.sqlite-shm` sit beside it). The counts
+need three projection tables:
- `projection_projects`: `project_id`, `workspace_root`, `deleted_at`.
- `projection_threads`: `thread_id`, `project_id`, `title`, `archived_at`,
@@ -27,51 +30,72 @@
- `projection_thread_messages`: `message_id` (primary key), `thread_id`,
`role` (`user` or `assistant`), `text`, `created_at` (ISO UTC).
-One more table is optional. In `orchestration_events`, each
+The JSON diagnostic reads one more. In `orchestration_events`, each
`thread.message-sent` event carries `metadata_json.origin`. Messages typed in
the T3 app carry an `appVersion` there. Messages sent through T3's API or MCP
-tools, which is how seats talk to each other, don't. See the cross-check below.
+tools, which is how seats talk to each other, don't.
The same directory also holds `secrets/`, `clerk-tokens.json` and other
settings files. The reader opens `state.sqlite` and nothing else, and it
selects named columns only, never `*`.
-## Reading it, with T3 running or not
+## 1. Reading it, with T3 running or not
-The file stays on disk whether T3 runs or not. The reader opens it with Node's
-built-in `node:sqlite` (`DatabaseSync`, `file:<path>?mode=ro`, `readOnly:
-true`). That needs no dependency, and Node 26.8.1 prints no warning for it. I
-read the live database this way today, while T3 was running, with no errors
-and no locks. A WAL reader sees every committed message, including those still
-in the `-wal` file.
-
-Two rules:
-- Never open with `immutable=1` and never copy the file. Both skip the WAL
- and silently lose the newest messages. A copy of the three files is also
- not atomic.
-- If T3 stopped uncleanly and left a `-wal` without its `-shm`, a read-only
- connection may be unable to rebuild the index. If the open fails, the
- ledger reports it and refuses. I have not tested this case or the fully
- stopped case. Both are acceptance checks below.
+The reader uses Node's built-in `node:sqlite` (`DatabaseSync`). That needs no
+dependency, and Node 26.8.1 (SQLite 3.53.4) prints no warning for it.
-## Jason or agent
+- **URI.** Build it with `pathToFileURL(dbPath)` and set `mode=ro` through
+ `searchParams`, then pass `readOnly: true`. A `?`, `#` or `%` in the home
+ path would break a string-built URI.
+- **One snapshot.** Run every query, from the schema checks through the
+ diagnostic, inside one `BEGIN` … `COMMIT`. In autocommit mode each
+ statement sees its own snapshot while T3 writes between them.
+- **Busy timeout.** Set `DatabaseSync`'s `timeout` to 5 s. A transient
+ `SQLITE_BUSY` during a T3 checkpoint then waits instead of failing. A busy
+ error after the timeout exits 1 like any open failure.
+- **No `immutable=1` and no copy.** Both lose the WAL. Filbert found worse
+ than lost messages: with a table created inside the WAL, `immutable=1`
+ fails with `no such table`.
+
+What happens on disk. Filbert and I both tested these in scratch
+directories:
+
+| State | Directory writable | Result |
+|---|---|---|
+| T3 running, writer attached, newest rows only in `-wal` | yes | reads them |
+| `-wal` without `-shm` (writer killed, `-shm` removed) | yes | reads the WAL rows and creates `-shm` |
+| `-wal` without `-shm` | no | open fails, SQLite 14 |
+| T3 stopped cleanly, no `-wal` or `-shm` | yes | reads, then leaves an empty `-wal` and a 32 KiB `-shm` |
+| T3 stopped cleanly | no | fails, SQLite 1544 "attempt to write a readonly database" |
+
+In every case the main file's bytes stayed the same. The last row is where
+Filbert and I differ. His review says the stopped-case read works with the
+directory read-only. In my run it failed with and without the read
+transaction. The build's test settles it. Either way a failed open is exit 1.
+
+So the accurate claim: the reader never writes the main database file. Like
+any SQLite connection, it may create or update `-wal` and `-shm` beside it
+and takes read locks in `-shm`. T3 opens normally afterwards.
+
+## 2. Jason or agent
Reuse the 6a rule. The first line of `text` decides: a T3 header or the tmux
preamble counts as agent, `control-board` as the sender counts as board, and
anything else counts as human. Messages with role `user` count; assistant
messages don't.
-6a has a defect this source would expose. Its regex allows only a lowercase
-class (`class=[a-z-]+`). Seats send uppercase classes: Sage's DECISION, INFO,
-REVIEW-REQUEST and REVIEW-NOTE, and my own REVIEW-REQUEST. In this project's
-threads, 16 real agent headers fail on that alone and would count as human.
-The fix is to make the class match case-insensitive. It belongs in this build
-or just before it, reviewed with it. The ms-communications table lists
-lowercase names, so the fix follows what seats send, not the table.
+The class fix rides in this build (Sage's ruling). HEAD's
+`packages/ledger/src/ledger.mjs:81` (tmux) and `:83` (T3) both allow only
+`class=[a-z-]+`. Both become case-insensitive. Seats send uppercase classes:
+Sage's DECISION, INFO, REVIEW-REQUEST and REVIEW-NOTE, and my own
+REVIEW-REQUEST. In this project's threads 16 real agent headers failed on
+that alone at 20:54Z. The ms-communications table lists lowercase names, so
+the fix follows what seats send, not the table.
Cross-check, read at 2026-09-26T20:54Z for the mosaic-stack project (209
user messages outside imported and deleted threads, every one with its
-`thread.message-sent` event):
+`thread.message-sent` event). Filbert's later read agreed, plus messages sent
+since.
| T3 origin | Header matches 6a | Count |
|---|---|---|
@@ -83,110 +107,193 @@
No message typed in the app carries a header, and every API message in this
project carries one of the three forms. The 14 free-text ones are older
Discord Bot thread headers such as `[from: SetSpark coordinator (…) -> to:
-Discord Bot (…)]`, written before the guide fixed the format. With the class
-fix they still count as human. That's 14 wrong human counts, all dated
-2026-09-17 to 2026-09-22.
-
-Recommendation: the header rule decides, as Sage asked. The reader also
-reports one diagnostic number, not used in any table: user messages the rule
-calls human that T3 recorded as sent through the API. That count is how the
-uppercase-class bug showed up, and it would catch the next format drift. The
-origin field is T3's internal metadata, not a documented contract, so it
-shouldn't decide anything. I'd make it JSON only, so Table 2's layout stays
-the same.
-
-## Thread to seat
-
-A thread counts for this checkout only if its project's `workspace_root` is
-the ledger's repository root. That is `/mnt/storage/src/mosaic-stack`, project
-`34050c07`.
-
-Thread IDs change whenever Jason starts a new thread for a seat, so there's no
-fixed map. T3-AGENT-COMMS.md already names threads after the seat ("Darkwing",
-"Sage", "Dewey in Claude"). Proposed rule: a thread belongs to seat `<s>` when
-`<s>` is a real directory under `agents/` and the lower-cased title equals
-`<s>` or starts with `<s>` followed by a space. Several threads can map to one
-seat. Their counts add up, as several Pi session files already do.
+Discord Bot (…)]`, written before the guide fixed the format. Sage ruled they
+stay as recorded: they count as human, dated 2026-09-17 to 2026-09-22.
+
+The header rule decides. The JSON also carries one diagnostic that feeds no
+table or total: user messages the rule calls human that T3 recorded as sent
+through the API. That number exposed the class bug and would catch the next
+format drift. `origin` is T3's internal metadata, not a documented contract,
+so it decides nothing. If `orchestration_events` or a column it needs is
+missing, the diagnostic reads `unknown` and the report goes on (Sage's
+ruling on F5). Missing tables the counts depend on still exit 1.
+
+## 3. Thread to seat
+
+**Project.** A thread counts for this checkout only if its project's
+`workspace_root` equals the ledger's repository root, byte for byte. The CLI
+already takes that root from the realpath of its own URL, today
+`/mnt/storage/src/mosaic-stack`, project `34050c07`. So a T3 project opened
+through the compatibility symlink `~/src/mosaic-stack-dev-test` doesn't
+match, and "no project row" is the right refusal. The README says so.
+
+**Title rule.** Thread IDs change whenever Jason starts a new thread for a
+seat, so there's no fixed map. T3-AGENT-COMMS.md already names threads after
+the seat ("Darkwing", "Sage", "Dewey in Claude"). A thread belongs to seat
+`<s>` when `<s>` is a real directory under `agents/` and the lower-cased
+title equals `<s>` or starts with `<s>` followed by a space. So "Sagebrush"
+stays unmapped. Several threads can map to one seat, and their counts add
+up, as several Pi session files already do.
Today that maps Sage, Darkwing, Filbert, Dewey and Rocko (one thread each,
-created 2026-09-26), plus "Darkwing in Claude" (archived) and "Dewey in
-Claude". Three threads map to no seat. Two are imported and excluded anyway
-("FINDINGS.md review" and "[dragon-lin:darkwing -> …"). The third is
-"Discord Bot" with 68 user messages: 54 without a header, and the 14
-free-text headers above. The guide's own advice, titles like `review:
-<topic>`, will produce more unmapped threads.
-
-Unmapped threads go in one Table 2 row, `t3:unmapped`, so Jason's messages
-there still count toward the Human column and the human-per-closed ratio. The
-other choice is to drop them, which would hide those 54 headerless prompts.
-That is Jason's decision. I recommend the row.
-
-A seat's row sums its Pi and T3 counts. JSON splits them by source. Nothing is
-counted twice: every T3 session today runs on `claudeAgent` or `codex`, which
-don't write `.pi/state`, and Filbert found no T3 header in any Pi log.
+created 2026-09-26, titles set by hand), plus "Darkwing in Claude" (archived)
+and "Dewey in Claude". Researcher has a directory and no thread. Three
+threads map to no seat. Two are imported and excluded anyway ("FINDINGS.md
+review" and "[dragon-lin:darkwing -> …"). The third is "Discord Bot" with 68
+user messages: 54 without a header, and the 14 free-text headers.
+
+Titles are current state, and T3 can write them itself. They go wrong three
+ways. T3 auto-titles an unnamed thread from Jason's first prompt, so "Rocko
+review of the plan" maps to rocko. A rename moves the whole history to
+another row. A seat thread titled for a topic drops into `t3:unmapped`.
+None of this changes the Human total or the human-per-closed ratio. It only
+moves counts between rows, but Gate F reads one seat's row.
+
+**Header cross-check.** The headers already say which seat a thread belongs
+to. For every user message whose header matches the fixed 6a rule and whose
+`to:` id equals the message's own `thread_id`:
+- in a mapped thread, the `to:` role, lower-cased, must equal that thread's
+ seat;
+- in an unmapped thread, the `to:` role must not be a seat name.
+
+A conflict exits 1 and names the thread id, its title and both roles. A
+header whose `to:` id is some other thread is not checked. The check reads
+message text only, not T3 metadata. In a live read at 21:02Z every header
+agreed: all 104 addressed to their own thread carried the full thread id and
+named that thread's seat (Sage 40, Darkwing 15, Filbert 18, Dewey 15, Rocko
+16).
+
+It catches a seat thread renamed to another seat or to a topic, once any
+agent writes to it. It also catches an auto-titled thread that agents
+address by a different seat. It misses a thread no agent ever writes to.
+Such a thread can only add human counts to a seat's row, never hide them, so
+for Gate F it errs toward a visible failure. The README says so.
+
+**Unmapped row.** Unmapped threads go in one Table 2 row, `t3:unmapped`
+(Sage's ruling), so their human messages still reach the Human column and
+the human-per-closed ratio.
+
+**Mapping in the JSON.** For each seat, the T3 thread ids and titles that
+made its row, and the unmapped thread ids and titles. Anyone checking a Gate
+F result can then see which threads the row came from.
+
+A seat's row sums its Pi and T3 counts, and the JSON splits them by source.
+Nothing is counted twice. Every T3 session today runs on `claudeAgent` or
+`codex`, which don't write `.pi/state`, and Filbert found no T3 header in any
+Pi log (6a record).
-Excluded, with the reason stated in the README:
+**Excluded,** with the reason stated in the README:
- Imported threads (`thread_id` starting `import:`, events marked
`historyImport`). They are partial copies of Claude Code sessions, not T3
traffic: 55 user messages in two threads here.
- Deleted threads (`deleted_at` set). Across all projects there are 3, with
3 messages. Archived threads count.
-
-## What fails closed
-
-With the T3 source on, each of these refuses the report with exit 1, the
-code the ledger already uses for unreadable session evidence. The report
-never falls back to Pi logs alone. As with `--no-issues`, `--no-t3` turns the
-source off, and the report then says T3 was not read.
-- The database is missing, unreadable, or won't open read-only (including
- the `-wal` without `-shm` case). This differs from the Pi reader, which
- treats a missing `.pi` as no messages. A missing Pi directory means no Pi
- seats ran here. A missing T3 database on this host means the path or T3
- changed, and a silent zero is the failure Gate F exists to prevent.
-- A required table or column is missing. The reader checks `PRAGMA
+- Threads in other T3 projects. Live, there is a project at `/home/jwoltje`
+ and a deleted one at `/mnt/storage/src`. A thread in either could work on
+ this repository and would not be counted. The workspace-root rule is still
+ the right one, but the README names this blind spot.
+
+## 4. What fails closed
+
+The source is on by default (Sage's ruling). `--no-t3` turns it off, and the
+report then says T3 was not read. `--t3-db <path>` reads another database
+file instead of `~/.t3/userdata/state.sqlite`. It exists for fixtures and
+gets the same checks.
+
+Each of these refuses the report with exit 1, the code the ledger already
+uses for unreadable session evidence. The report never falls back to Pi logs
+alone. Where the database is missing or won't open, the message names
+`--no-t3`.
+- The database is missing or unreadable, or won't open read-only. That
+ includes a directory that isn't writable when SQLite needs to create
+ `-shm`, and a busy error after the timeout. The Pi reader treats a missing
+ `.pi` as no messages, and this departs from it on purpose. A missing Pi
+ directory means no Pi seats ran here. A missing T3 database on this host
+ means the path or T3 changed, and a silent zero is the failure Gate F
+ exists to prevent.
+- `~/.t3`, `~/.t3/userdata` or `state.sqlite` is a symlink. With `--t3-db`,
+ the file and its directory are checked. The Pi reader checks every
+ ancestor too, but it skips symlinked entries. Skipping one named file
+ would be another silent zero, so this reader refuses.
+- A table or column the counts need is missing. The reader checks `PRAGMA
table_info` and names what's missing. This catches a T3 upgrade that
changes the schema.
- No project row, or more than one non-deleted row, for this repository root.
- A counted row has a bad `role`, non-string `text`, or a `created_at` that
doesn't parse. The Pi reader already refuses malformed JSONL and bad
timestamps the same way.
-- `state.sqlite` or `~/.t3/userdata` is a symlink. The Pi reader skips
- symlinked entries instead. For one named file, skipping would be another
- silent zero, so this reader refuses.
-
-The source never writes to the database. It never reads other files in
-`~/.t3`, and it passes no message text beyond `messageKind` and
-`issueNumbers`, the same rule as for Pi logs. The one outside effect is
-SQLite's own: a WAL reader takes read locks in the `-shm` file, as T3's own
-connections do.
-
-## Decisions for Jason
-
-1. The source is on by default, with `--no-t3` to turn it off. The other
- choice is off by default with `--t3` to turn it on. I recommend on by
- default, because Gate F exists to count these messages.
-2. Unmapped threads get a `t3:unmapped` row. The other choice is to drop
- them. I recommend the row.
-3. The 14 free-text headers from 09-17 to 09-22 stay counted as human. Fixing
- them would mean loosening the header grammar for history only, and I don't
- recommend it.
-
-## Acceptance for the build
-
-- Fixture databases built with `node:sqlite` in a temp dir, in WAL mode:
- seat threads and an unmapped thread; imported, deleted and archived
- threads; all three header forms, uppercase classes included; a message
- outside the date range; another project with the same seat titles.
-- Each fail-closed case above has its own test, including a schema column
- removed and `-wal` without `-shm`. One test opens a database whose newest
- message is still in the WAL and counts it. Another reads a database closed
- cleanly with no writer attached, which is the T3-stopped case.
-- The class fix is proven against HEAD's `messageKind`: an uppercase class
- counts as agent after the fix and as human before it.
+- A header conflicts with the title mapping (section 3).
+
+The reader never reads other files in `~/.t3`. It passes no message text
+beyond `messageKind`, `issueNumbers` and the header's `to:` role and id, the
+same rule as for Pi logs.
+
+## 5. Rulings
+
+Sage ruled on the three questions R1 put to Jason, as lead calls:
+1. The source is on by default. A missing or unreadable database exits 1,
+ and the message names `--no-t3`.
+2. Unmapped threads get the `t3:unmapped` row.
+3. The 14 free-text headers stay as recorded. They show only in the JSON
+ diagnostic.
+
+Sage also ruled that the class fix rides in this build, and that a missing
+diagnostic table reads `unknown` (F5).
+
+## 6. Acceptance for the build
+
+**No test opens the real `~/.t3`.** Both places in
+`packages/ledger/tests/ledger.test.mjs` that spawn `cli.mjs` (the shared
+`run()` helper and the direct `spawnSync` at line 66) set `HOME` to the
+fixture's temp directory. A test that forgets `--t3-db` or `--no-t3` then
+finds no database and fails closed. The existing tests aren't about T3. Each
+gets an empty fixture database at the fixture `HOME`'s default path, with
+one project row for the fixture root. So they run with the source on, and
+their expected rows don't change. One test asserts that a `HOME` with no
+database exits 1 and names `--no-t3`.
+
+Fixture databases are built with `node:sqlite` in a temp directory, in WAL
+mode:
+- seat threads and an unmapped thread; imported, deleted and archived
+ threads; a message outside the date range;
+- all three header forms, with uppercase classes in both the tmux preamble
+ and the T3 header;
+- a thread with the same seat title in another project;
+- a seat thread renamed to another seat, with an agent header to its own
+ id, which exits 1;
+- a "Sagebrush" title, which stays unmapped;
+- a thread titled "Researcher", which maps to the seat that has no thread
+ live.
+
+WAL states, each with its own test:
+- The newest message is only in `-wal`, with the writer still attached (the
+ live-T3 case). It is counted.
+- T3 stopped: the database closed cleanly with no writer. Counts are
+ correct, and the main file's bytes are unchanged afterwards.
+- `-wal` without `-shm` in a writable directory: made by a child writer with
+ `wal_autocheckpoint=0` that is SIGKILLed, then `-shm` deleted. The WAL
+ rows are counted.
+- `-wal` without `-shm` in a directory that isn't writable: exit 1, naming
+ `--no-t3`. Skipped when the tests run as root, where the mode bits don't
+ bind.
+- The stopped case in a directory that isn't writable: exit 1, naming
+ `--no-t3`, as I measured it. If the build reads there instead, the builder
+ changes this test to assert correct counts and records the correction in
+ the BUILD-LOG entry. Skipped as root too.
+
+Also:
+- Each other fail-closed case in section 4 has its own test, including a
+ removed schema column and each symlink.
+- A missing `orchestration_events` gives `unknown` for the diagnostic and
+ the same counts.
+- The class fix is proven against HEAD's `messageKind`. An uppercase class
+ in either preamble counts as agent after the fix and as human before it.
+- The JSON lists each seat's threads and the unmapped threads.
- A read against the live database gives the counts in this brief, allowing
- for messages sent since.
-- The ledger README's counting rules name the new source, the mapping rule
- and the exclusions.
+ for messages sent since. It exits 0 with no header conflict.
+- The ledger README's counting rules name the new source, both flags, the
+ mapping rule and the header check, and the exclusions. That includes the
+ symlinked-checkout case and the other-project blind spot.
- No suite runs the ledger tests, so the BUILD-LOG entry names the test file.
## Not in scope
@@ -194,5 +301,6 @@
- Claude Code transcripts (`~/.claude/projects`) and Codex sessions
(`~/.codex/sessions`). T3's database already holds every message T3
delivered, so those files would only duplicate it.
-- Any write to T3, any T3 API call, or anything that needs T3 running.
+- Any T3 API call, anything that needs T3 running, and any write beyond
+ SQLite's own `-wal` and `-shm` handling.
- Fixing the two tmux misclassifications Filbert found in 6a.
+198
View File
@@ -0,0 +1,198 @@
# Ledger: a read-only T3 thread source for Table 2 (Gate F brief)
Brief only, no code. Darkwing wrote it on 2026-09-26 at Sage's request. Filbert
reviews it, and Jason sees it on the decision sheet before anyone builds it.
Issue #1506.
## Why
Table 2 counts user messages per seat from `.pi/state/<seat>/sessions/*.jsonl`
only. Development seats now run in T3 on the Claude and Codex harnesses, so
their prompts, Jason's included, never reach a Pi log. Today the Human column
can't see T3 at all, and the zero it shows for T3 seats means "no source", not
"no human prompts". 6a (ef0020ad) taught `messageKind` the T3 header, but no
source the ledger reads contains one. Gate F (QUEUE row 6) passes when
Filbert's item closes with zero human messages from Jason. While the ledger
can't see T3, a zero there proves nothing.
## Where T3 keeps messages
T3 keeps its state in one SQLite database, `~/.t3/userdata/state.sqlite`, in
WAL mode (`state.sqlite-wal` and `state.sqlite-shm` sit beside it). Three
projection tables are enough:
- `projection_projects`: `project_id`, `workspace_root`, `deleted_at`.
- `projection_threads`: `thread_id`, `project_id`, `title`, `archived_at`,
`deleted_at`.
- `projection_thread_messages`: `message_id` (primary key), `thread_id`,
`role` (`user` or `assistant`), `text`, `created_at` (ISO UTC).
One more table is optional. In `orchestration_events`, each
`thread.message-sent` event carries `metadata_json.origin`. Messages typed in
the T3 app carry an `appVersion` there. Messages sent through T3's API or MCP
tools, which is how seats talk to each other, don't. See the cross-check below.
The same directory also holds `secrets/`, `clerk-tokens.json` and other
settings files. The reader opens `state.sqlite` and nothing else, and it
selects named columns only, never `*`.
## Reading it, with T3 running or not
The file stays on disk whether T3 runs or not. The reader opens it with Node's
built-in `node:sqlite` (`DatabaseSync`, `file:<path>?mode=ro`, `readOnly:
true`). That needs no dependency, and Node 26.8.1 prints no warning for it. I
read the live database this way today, while T3 was running, with no errors
and no locks. A WAL reader sees every committed message, including those still
in the `-wal` file.
Two rules:
- Never open with `immutable=1` and never copy the file. Both skip the WAL
and silently lose the newest messages. A copy of the three files is also
not atomic.
- If T3 stopped uncleanly and left a `-wal` without its `-shm`, a read-only
connection may be unable to rebuild the index. If the open fails, the
ledger reports it and refuses. I have not tested this case or the fully
stopped case. Both are acceptance checks below.
## Jason or agent
Reuse the 6a rule. The first line of `text` decides: a T3 header or the tmux
preamble counts as agent, `control-board` as the sender counts as board, and
anything else counts as human. Messages with role `user` count; assistant
messages don't.
6a has a defect this source would expose. Its regex allows only a lowercase
class (`class=[a-z-]+`). Seats send uppercase classes: Sage's DECISION, INFO,
REVIEW-REQUEST and REVIEW-NOTE, and my own REVIEW-REQUEST. In this project's
threads, 16 real agent headers fail on that alone and would count as human.
The fix is to make the class match case-insensitive. It belongs in this build
or just before it, reviewed with it. The ms-communications table lists
lowercase names, so the fix follows what seats send, not the table.
Cross-check, read at 2026-09-26T20:54Z for the mosaic-stack project (209
user messages outside imported and deleted threads, every one with its
`thread.message-sent` event):
| T3 origin | Header matches 6a | Count |
|---|---|---|
| typed in the app (has `appVersion`) | no | 99 |
| sent through the API (no `appVersion`) | yes | 80 |
| sent through the API | no, uppercase class | 16 |
| sent through the API | no, free-text roles | 14 |
No message typed in the app carries a header, and every API message in this
project carries one of the three forms. The 14 free-text ones are older
Discord Bot thread headers such as `[from: SetSpark coordinator (…) -> to:
Discord Bot (…)]`, written before the guide fixed the format. With the class
fix they still count as human. That's 14 wrong human counts, all dated
2026-09-17 to 2026-09-22.
Recommendation: the header rule decides, as Sage asked. The reader also
reports one diagnostic number, not used in any table: user messages the rule
calls human that T3 recorded as sent through the API. That count is how the
uppercase-class bug showed up, and it would catch the next format drift. The
origin field is T3's internal metadata, not a documented contract, so it
shouldn't decide anything. I'd make it JSON only, so Table 2's layout stays
the same.
## Thread to seat
A thread counts for this checkout only if its project's `workspace_root` is
the ledger's repository root. That is `/mnt/storage/src/mosaic-stack`, project
`34050c07`.
Thread IDs change whenever Jason starts a new thread for a seat, so there's no
fixed map. T3-AGENT-COMMS.md already names threads after the seat ("Darkwing",
"Sage", "Dewey in Claude"). Proposed rule: a thread belongs to seat `<s>` when
`<s>` is a real directory under `agents/` and the lower-cased title equals
`<s>` or starts with `<s>` followed by a space. Several threads can map to one
seat. Their counts add up, as several Pi session files already do.
Today that maps Sage, Darkwing, Filbert, Dewey and Rocko (one thread each,
created 2026-09-26), plus "Darkwing in Claude" (archived) and "Dewey in
Claude". Three threads map to no seat. Two are imported and excluded anyway
("FINDINGS.md review" and "[dragon-lin:darkwing -> …"). The third is
"Discord Bot" with 68 user messages: 54 without a header, and the 14
free-text headers above. The guide's own advice, titles like `review:
<topic>`, will produce more unmapped threads.
Unmapped threads go in one Table 2 row, `t3:unmapped`, so Jason's messages
there still count toward the Human column and the human-per-closed ratio. The
other choice is to drop them, which would hide those 54 headerless prompts.
That is Jason's decision. I recommend the row.
A seat's row sums its Pi and T3 counts. JSON splits them by source. Nothing is
counted twice: every T3 session today runs on `claudeAgent` or `codex`, which
don't write `.pi/state`, and Filbert found no T3 header in any Pi log.
Excluded, with the reason stated in the README:
- Imported threads (`thread_id` starting `import:`, events marked
`historyImport`). They are partial copies of Claude Code sessions, not T3
traffic: 55 user messages in two threads here.
- Deleted threads (`deleted_at` set). Across all projects there are 3, with
3 messages. Archived threads count.
## What fails closed
With the T3 source on, each of these refuses the report with exit 1, the
code the ledger already uses for unreadable session evidence. The report
never falls back to Pi logs alone. As with `--no-issues`, `--no-t3` turns the
source off, and the report then says T3 was not read.
- The database is missing, unreadable, or won't open read-only (including
the `-wal` without `-shm` case). This differs from the Pi reader, which
treats a missing `.pi` as no messages. A missing Pi directory means no Pi
seats ran here. A missing T3 database on this host means the path or T3
changed, and a silent zero is the failure Gate F exists to prevent.
- A required table or column is missing. The reader checks `PRAGMA
table_info` and names what's missing. This catches a T3 upgrade that
changes the schema.
- No project row, or more than one non-deleted row, for this repository root.
- A counted row has a bad `role`, non-string `text`, or a `created_at` that
doesn't parse. The Pi reader already refuses malformed JSONL and bad
timestamps the same way.
- `state.sqlite` or `~/.t3/userdata` is a symlink. The Pi reader skips
symlinked entries instead. For one named file, skipping would be another
silent zero, so this reader refuses.
The source never writes to the database. It never reads other files in
`~/.t3`, and it passes no message text beyond `messageKind` and
`issueNumbers`, the same rule as for Pi logs. The one outside effect is
SQLite's own: a WAL reader takes read locks in the `-shm` file, as T3's own
connections do.
## Decisions for Jason
1. The source is on by default, with `--no-t3` to turn it off. The other
choice is off by default with `--t3` to turn it on. I recommend on by
default, because Gate F exists to count these messages.
2. Unmapped threads get a `t3:unmapped` row. The other choice is to drop
them. I recommend the row.
3. The 14 free-text headers from 09-17 to 09-22 stay counted as human. Fixing
them would mean loosening the header grammar for history only, and I don't
recommend it.
## Acceptance for the build
- Fixture databases built with `node:sqlite` in a temp dir, in WAL mode:
seat threads and an unmapped thread; imported, deleted and archived
threads; all three header forms, uppercase classes included; a message
outside the date range; another project with the same seat titles.
- Each fail-closed case above has its own test, including a schema column
removed and `-wal` without `-shm`. One test opens a database whose newest
message is still in the WAL and counts it. Another reads a database closed
cleanly with no writer attached, which is the T3-stopped case.
- The class fix is proven against HEAD's `messageKind`: an uppercase class
counts as agent after the fix and as human before it.
- A read against the live database gives the counts in this brief, allowing
for messages sent since.
- The ledger README's counting rules name the new source, the mapping rule
and the exclusions.
- No suite runs the ledger tests, so the BUILD-LOG entry names the test file.
## Not in scope
- Claude Code transcripts (`~/.claude/projects`) and Codex sessions
(`~/.codex/sessions`). T3's database already holds every message T3
delivered, so those files would only duplicate it.
- Any write to T3, any T3 API call, or anything that needs T3 running.
- Fixing the two tmux misclassifications Filbert found in 6a.
@@ -0,0 +1,74 @@
--- r2.md
+++ docs/plans/2026-09-26_ledger-t3-source.md
@@ -3,9 +3,9 @@
Brief only, no code. Darkwing wrote it on 2026-09-26 at Sage's request, issue
#1506. R1 (sha256 08959a05) went to Filbert, whose review asked for
revisions: `agents/filbert/work/ledger-t3-source-review-2026-09-26.md`, sha256
-19dda29a. This is R2. It takes every finding, and it records Sage's rulings
-on the three open questions. Section 1 has one measurement that differs from
-the review.
+19dda29a. R2 (sha256 e8300cb6) took every finding and recorded Sage's
+rulings on the three open questions. Filbert approved R2 with three nits,
+review sha256 bb02d8d3. This is R3, which takes the nits.
## Why
@@ -68,10 +68,10 @@
| T3 stopped cleanly, no `-wal` or `-shm` | yes | reads, then leaves an empty `-wal` and a 32 KiB `-shm` |
| T3 stopped cleanly | no | fails, SQLite 1544 "attempt to write a readonly database" |
-In every case the main file's bytes stayed the same. The last row is where
-Filbert and I differ. His review says the stopped-case read works with the
-directory read-only. In my run it failed with and without the read
-transaction. The build's test settles it. Either way a failed open is exit 1.
+In every case the main file's bytes stayed the same. Filbert's first
+review said the last case reads. His test had reused a database whose empty
+`-wal` and `-shm` were still present. On a true clean stop he also got 1544,
+and his review records the correction. A failed open is exit 1.
So the accurate claim: the reader never writes the main database file. Like
any SQLite connection, it may create or update `-wal` and `-shm` beside it
@@ -198,7 +198,9 @@
The source is on by default (Sage's ruling). `--no-t3` turns it off, and the
report then says T3 was not read. `--t3-db <path>` reads another database
file instead of `~/.t3/userdata/state.sqlite`. It exists for fixtures and
-gets the same checks.
+gets the same checks. The JSON records the database path read and whether
+it was the default. When it wasn't, the text report adds one line naming the
+path, so a Gate F result can't come from a fixture unnoticed.
Each of these refuses the report with exit 1, the code the ledger already
uses for unreadable session evidence. The report never falls back to Pi logs
@@ -248,7 +250,9 @@
fixture's temp directory. A test that forgets `--t3-db` or `--no-t3` then
finds no database and fails closed. The existing tests aren't about T3. Each
gets an empty fixture database at the fixture `HOME`'s default path, with
-one project row for the fixture root. So they run with the source on, and
+one project row for the fixture root. That row stores
+`fs.realpathSync(root)`, because the CLI resolves its root through realpath
+and a symlinked temp directory would otherwise not match. So they run with the source on, and
their expected rows don't change. One test asserts that a `HOME` with no
database exits 1 and names `--no-t3`.
@@ -277,9 +281,7 @@
`--no-t3`. Skipped when the tests run as root, where the mode bits don't
bind.
- The stopped case in a directory that isn't writable: exit 1, naming
- `--no-t3`, as I measured it. If the build reads there instead, the builder
- changes this test to assert correct counts and records the correction in
- the BUILD-LOG entry. Skipped as root too.
+ `--no-t3`. Skipped as root too.
Also:
- Each other fail-closed case in section 4 has its own test, including a
@@ -288,7 +290,9 @@
the same counts.
- The class fix is proven against HEAD's `messageKind`. An uppercase class
in either preamble counts as agent after the fix and as human before it.
-- The JSON lists each seat's threads and the unmapped threads.
+- The JSON lists each seat's threads and the unmapped threads, and the
+ database path with whether it was the default. A `--t3-db` run prints the
+ path line in the text report, and a default run doesn't.
- A read against the live database gives the counts in this brief, allowing
for messages sent since. It exits 0 with no header conflict.
- The ledger README's counting rules name the new source, both flags, the
+306
View File
@@ -0,0 +1,306 @@
# Ledger: a read-only T3 thread source for Table 2 (Gate F brief)
Brief only, no code. Darkwing wrote it on 2026-09-26 at Sage's request, issue
#1506. R1 (sha256 08959a05) went to Filbert, whose review asked for
revisions: `agents/filbert/work/ledger-t3-source-review-2026-09-26.md`, sha256
19dda29a. This is R2. It takes every finding, and it records Sage's rulings
on the three open questions. Section 1 has one measurement that differs from
the review.
## Why
Table 2 counts user messages per seat from `.pi/state/<seat>/sessions/*.jsonl`
only. Development seats now run in T3 on the Claude and Codex harnesses, so
their prompts, Jason's included, never reach a Pi log. Today the Human column
can't see T3 at all, and the zero it shows for T3 seats means "no source", not
"no human prompts". 6a (ef0020ad) taught `messageKind` the T3 header, but no
source the ledger reads contains one. Gate F (QUEUE row 6) passes when
Filbert's item closes with zero human messages from Jason. While the ledger
can't see T3, a zero there proves nothing.
## Where T3 keeps messages
T3 keeps its state in one SQLite database, `~/.t3/userdata/state.sqlite`, in
WAL mode (`state.sqlite-wal` and `state.sqlite-shm` sit beside it). The counts
need three projection tables:
- `projection_projects`: `project_id`, `workspace_root`, `deleted_at`.
- `projection_threads`: `thread_id`, `project_id`, `title`, `archived_at`,
`deleted_at`.
- `projection_thread_messages`: `message_id` (primary key), `thread_id`,
`role` (`user` or `assistant`), `text`, `created_at` (ISO UTC).
The JSON diagnostic reads one more. In `orchestration_events`, each
`thread.message-sent` event carries `metadata_json.origin`. Messages typed in
the T3 app carry an `appVersion` there. Messages sent through T3's API or MCP
tools, which is how seats talk to each other, don't.
The same directory also holds `secrets/`, `clerk-tokens.json` and other
settings files. The reader opens `state.sqlite` and nothing else, and it
selects named columns only, never `*`.
## 1. Reading it, with T3 running or not
The reader uses Node's built-in `node:sqlite` (`DatabaseSync`). That needs no
dependency, and Node 26.8.1 (SQLite 3.53.4) prints no warning for it.
- **URI.** Build it with `pathToFileURL(dbPath)` and set `mode=ro` through
`searchParams`, then pass `readOnly: true`. A `?`, `#` or `%` in the home
path would break a string-built URI.
- **One snapshot.** Run every query, from the schema checks through the
diagnostic, inside one `BEGIN` … `COMMIT`. In autocommit mode each
statement sees its own snapshot while T3 writes between them.
- **Busy timeout.** Set `DatabaseSync`'s `timeout` to 5 s. A transient
`SQLITE_BUSY` during a T3 checkpoint then waits instead of failing. A busy
error after the timeout exits 1 like any open failure.
- **No `immutable=1` and no copy.** Both lose the WAL. Filbert found worse
than lost messages: with a table created inside the WAL, `immutable=1`
fails with `no such table`.
What happens on disk. Filbert and I both tested these in scratch
directories:
| State | Directory writable | Result |
|---|---|---|
| T3 running, writer attached, newest rows only in `-wal` | yes | reads them |
| `-wal` without `-shm` (writer killed, `-shm` removed) | yes | reads the WAL rows and creates `-shm` |
| `-wal` without `-shm` | no | open fails, SQLite 14 |
| T3 stopped cleanly, no `-wal` or `-shm` | yes | reads, then leaves an empty `-wal` and a 32 KiB `-shm` |
| T3 stopped cleanly | no | fails, SQLite 1544 "attempt to write a readonly database" |
In every case the main file's bytes stayed the same. The last row is where
Filbert and I differ. His review says the stopped-case read works with the
directory read-only. In my run it failed with and without the read
transaction. The build's test settles it. Either way a failed open is exit 1.
So the accurate claim: the reader never writes the main database file. Like
any SQLite connection, it may create or update `-wal` and `-shm` beside it
and takes read locks in `-shm`. T3 opens normally afterwards.
## 2. Jason or agent
Reuse the 6a rule. The first line of `text` decides: a T3 header or the tmux
preamble counts as agent, `control-board` as the sender counts as board, and
anything else counts as human. Messages with role `user` count; assistant
messages don't.
The class fix rides in this build (Sage's ruling). HEAD's
`packages/ledger/src/ledger.mjs:81` (tmux) and `:83` (T3) both allow only
`class=[a-z-]+`. Both become case-insensitive. Seats send uppercase classes:
Sage's DECISION, INFO, REVIEW-REQUEST and REVIEW-NOTE, and my own
REVIEW-REQUEST. In this project's threads 16 real agent headers failed on
that alone at 20:54Z. The ms-communications table lists lowercase names, so
the fix follows what seats send, not the table.
Cross-check, read at 2026-09-26T20:54Z for the mosaic-stack project (209
user messages outside imported and deleted threads, every one with its
`thread.message-sent` event). Filbert's later read agreed, plus messages sent
since.
| T3 origin | Header matches 6a | Count |
|---|---|---|
| typed in the app (has `appVersion`) | no | 99 |
| sent through the API (no `appVersion`) | yes | 80 |
| sent through the API | no, uppercase class | 16 |
| sent through the API | no, free-text roles | 14 |
No message typed in the app carries a header, and every API message in this
project carries one of the three forms. The 14 free-text ones are older
Discord Bot thread headers such as `[from: SetSpark coordinator (…) -> to:
Discord Bot (…)]`, written before the guide fixed the format. Sage ruled they
stay as recorded: they count as human, dated 2026-09-17 to 2026-09-22.
The header rule decides. The JSON also carries one diagnostic that feeds no
table or total: user messages the rule calls human that T3 recorded as sent
through the API. That number exposed the class bug and would catch the next
format drift. `origin` is T3's internal metadata, not a documented contract,
so it decides nothing. If `orchestration_events` or a column it needs is
missing, the diagnostic reads `unknown` and the report goes on (Sage's
ruling on F5). Missing tables the counts depend on still exit 1.
## 3. Thread to seat
**Project.** A thread counts for this checkout only if its project's
`workspace_root` equals the ledger's repository root, byte for byte. The CLI
already takes that root from the realpath of its own URL, today
`/mnt/storage/src/mosaic-stack`, project `34050c07`. So a T3 project opened
through the compatibility symlink `~/src/mosaic-stack-dev-test` doesn't
match, and "no project row" is the right refusal. The README says so.
**Title rule.** Thread IDs change whenever Jason starts a new thread for a
seat, so there's no fixed map. T3-AGENT-COMMS.md already names threads after
the seat ("Darkwing", "Sage", "Dewey in Claude"). A thread belongs to seat
`<s>` when `<s>` is a real directory under `agents/` and the lower-cased
title equals `<s>` or starts with `<s>` followed by a space. So "Sagebrush"
stays unmapped. Several threads can map to one seat, and their counts add
up, as several Pi session files already do.
Today that maps Sage, Darkwing, Filbert, Dewey and Rocko (one thread each,
created 2026-09-26, titles set by hand), plus "Darkwing in Claude" (archived)
and "Dewey in Claude". Researcher has a directory and no thread. Three
threads map to no seat. Two are imported and excluded anyway ("FINDINGS.md
review" and "[dragon-lin:darkwing -> …"). The third is "Discord Bot" with 68
user messages: 54 without a header, and the 14 free-text headers.
Titles are current state, and T3 can write them itself. They go wrong three
ways. T3 auto-titles an unnamed thread from Jason's first prompt, so "Rocko
review of the plan" maps to rocko. A rename moves the whole history to
another row. A seat thread titled for a topic drops into `t3:unmapped`.
None of this changes the Human total or the human-per-closed ratio. It only
moves counts between rows, but Gate F reads one seat's row.
**Header cross-check.** The headers already say which seat a thread belongs
to. For every user message whose header matches the fixed 6a rule and whose
`to:` id equals the message's own `thread_id`:
- in a mapped thread, the `to:` role, lower-cased, must equal that thread's
seat;
- in an unmapped thread, the `to:` role must not be a seat name.
A conflict exits 1 and names the thread id, its title and both roles. A
header whose `to:` id is some other thread is not checked. The check reads
message text only, not T3 metadata. In a live read at 21:02Z every header
agreed: all 104 addressed to their own thread carried the full thread id and
named that thread's seat (Sage 40, Darkwing 15, Filbert 18, Dewey 15, Rocko
16).
It catches a seat thread renamed to another seat or to a topic, once any
agent writes to it. It also catches an auto-titled thread that agents
address by a different seat. It misses a thread no agent ever writes to.
Such a thread can only add human counts to a seat's row, never hide them, so
for Gate F it errs toward a visible failure. The README says so.
**Unmapped row.** Unmapped threads go in one Table 2 row, `t3:unmapped`
(Sage's ruling), so their human messages still reach the Human column and
the human-per-closed ratio.
**Mapping in the JSON.** For each seat, the T3 thread ids and titles that
made its row, and the unmapped thread ids and titles. Anyone checking a Gate
F result can then see which threads the row came from.
A seat's row sums its Pi and T3 counts, and the JSON splits them by source.
Nothing is counted twice. Every T3 session today runs on `claudeAgent` or
`codex`, which don't write `.pi/state`, and Filbert found no T3 header in any
Pi log (6a record).
**Excluded,** with the reason stated in the README:
- Imported threads (`thread_id` starting `import:`, events marked
`historyImport`). They are partial copies of Claude Code sessions, not T3
traffic: 55 user messages in two threads here.
- Deleted threads (`deleted_at` set). Across all projects there are 3, with
3 messages. Archived threads count.
- Threads in other T3 projects. Live, there is a project at `/home/jwoltje`
and a deleted one at `/mnt/storage/src`. A thread in either could work on
this repository and would not be counted. The workspace-root rule is still
the right one, but the README names this blind spot.
## 4. What fails closed
The source is on by default (Sage's ruling). `--no-t3` turns it off, and the
report then says T3 was not read. `--t3-db <path>` reads another database
file instead of `~/.t3/userdata/state.sqlite`. It exists for fixtures and
gets the same checks.
Each of these refuses the report with exit 1, the code the ledger already
uses for unreadable session evidence. The report never falls back to Pi logs
alone. Where the database is missing or won't open, the message names
`--no-t3`.
- The database is missing or unreadable, or won't open read-only. That
includes a directory that isn't writable when SQLite needs to create
`-shm`, and a busy error after the timeout. The Pi reader treats a missing
`.pi` as no messages, and this departs from it on purpose. A missing Pi
directory means no Pi seats ran here. A missing T3 database on this host
means the path or T3 changed, and a silent zero is the failure Gate F
exists to prevent.
- `~/.t3`, `~/.t3/userdata` or `state.sqlite` is a symlink. With `--t3-db`,
the file and its directory are checked. The Pi reader checks every
ancestor too, but it skips symlinked entries. Skipping one named file
would be another silent zero, so this reader refuses.
- A table or column the counts need is missing. The reader checks `PRAGMA
table_info` and names what's missing. This catches a T3 upgrade that
changes the schema.
- No project row, or more than one non-deleted row, for this repository root.
- A counted row has a bad `role`, non-string `text`, or a `created_at` that
doesn't parse. The Pi reader already refuses malformed JSONL and bad
timestamps the same way.
- A header conflicts with the title mapping (section 3).
The reader never reads other files in `~/.t3`. It passes no message text
beyond `messageKind`, `issueNumbers` and the header's `to:` role and id, the
same rule as for Pi logs.
## 5. Rulings
Sage ruled on the three questions R1 put to Jason, as lead calls:
1. The source is on by default. A missing or unreadable database exits 1,
and the message names `--no-t3`.
2. Unmapped threads get the `t3:unmapped` row.
3. The 14 free-text headers stay as recorded. They show only in the JSON
diagnostic.
Sage also ruled that the class fix rides in this build, and that a missing
diagnostic table reads `unknown` (F5).
## 6. Acceptance for the build
**No test opens the real `~/.t3`.** Both places in
`packages/ledger/tests/ledger.test.mjs` that spawn `cli.mjs` (the shared
`run()` helper and the direct `spawnSync` at line 66) set `HOME` to the
fixture's temp directory. A test that forgets `--t3-db` or `--no-t3` then
finds no database and fails closed. The existing tests aren't about T3. Each
gets an empty fixture database at the fixture `HOME`'s default path, with
one project row for the fixture root. So they run with the source on, and
their expected rows don't change. One test asserts that a `HOME` with no
database exits 1 and names `--no-t3`.
Fixture databases are built with `node:sqlite` in a temp directory, in WAL
mode:
- seat threads and an unmapped thread; imported, deleted and archived
threads; a message outside the date range;
- all three header forms, with uppercase classes in both the tmux preamble
and the T3 header;
- a thread with the same seat title in another project;
- a seat thread renamed to another seat, with an agent header to its own
id, which exits 1;
- a "Sagebrush" title, which stays unmapped;
- a thread titled "Researcher", which maps to the seat that has no thread
live.
WAL states, each with its own test:
- The newest message is only in `-wal`, with the writer still attached (the
live-T3 case). It is counted.
- T3 stopped: the database closed cleanly with no writer. Counts are
correct, and the main file's bytes are unchanged afterwards.
- `-wal` without `-shm` in a writable directory: made by a child writer with
`wal_autocheckpoint=0` that is SIGKILLed, then `-shm` deleted. The WAL
rows are counted.
- `-wal` without `-shm` in a directory that isn't writable: exit 1, naming
`--no-t3`. Skipped when the tests run as root, where the mode bits don't
bind.
- The stopped case in a directory that isn't writable: exit 1, naming
`--no-t3`, as I measured it. If the build reads there instead, the builder
changes this test to assert correct counts and records the correction in
the BUILD-LOG entry. Skipped as root too.
Also:
- Each other fail-closed case in section 4 has its own test, including a
removed schema column and each symlink.
- A missing `orchestration_events` gives `unknown` for the diagnostic and
the same counts.
- The class fix is proven against HEAD's `messageKind`. An uppercase class
in either preamble counts as agent after the fix and as human before it.
- The JSON lists each seat's threads and the unmapped threads.
- A read against the live database gives the counts in this brief, allowing
for messages sent since. It exits 0 with no header conflict.
- The ledger README's counting rules name the new source, both flags, the
mapping rule and the header check, and the exclusions. That includes the
symlinked-checkout case and the other-project blind spot.
- No suite runs the ledger tests, so the BUILD-LOG entry names the test file.
## Not in scope
- Claude Code transcripts (`~/.claude/projects`) and Codex sessions
(`~/.codex/sessions`). T3's database already holds every message T3
delivered, so those files would only duplicate it.
- Any T3 API call, anything that needs T3 running, and any write beyond
SQLite's own `-wal` and `-shm` handling.
- Fixing the two tmux misclassifications Filbert found in 6a.
@@ -0,0 +1,317 @@
# Gate F brief: Filbert's review of the T3 ledger source
Reviewer: Filbert, 2026-09-26. Requested by Sage.
Candidate: `docs/plans/2026-09-26_ledger-t3-source.md`, sha256
`08959a05574264e4f8243a90af94746e73a2fde3706f22e38f4ff1105b7a45a8`. I
verified the hash. The file is uncommitted and has no code.
I reviewed it against Sage's rulings, not as open choices:
1. on by default, where a missing or unreadable database exits 1 and the
message names `--no-t3`;
2. the `t3:unmapped` row stays;
3. the 14 free-text Discord Bot headers stay as recorded;
4. case-insensitive classes (the 6a fix) are folded in.
**Verdict: revise.** The design is sound, and the counts check out against
the live database. Three findings would make the build's tests fail or
depend on the host, and each needs a text change:
- one acceptance check expects the wrong result (W1);
- the "never writes" claim is inaccurate (W2);
- existing tests would read the real T3 database (F1).
The rest are smaller additions. None needs a new ruling from Sage or Jason.
## What I checked
- **Scratch WAL tests** with `node:sqlite` on Node 26.8.1 (SQLite 3.53.4),
in `/tmp`.
- **The live database**, opened read-only with `mode=ro`. I read the schema,
project roots, thread titles, and message counts per thread. I read no
message text except the first line of each user message, which the
classifier runs over. I printed text only for my own thread's one human
message.
- **`packages/ledger/src/ledger.mjs` and `tests/ledger.test.mjs`** at HEAD.
The brief's numbers hold:
| Check | Live result |
|---|---|
| Discord Bot user messages | 68 |
| Typed-in-app (human) messages, outside imported and deleted threads | 113 = the brief's 99 + 14 |
| Agent messages with the class fix | 101 = the brief's 80 + 16, plus 5 sent since 20:54Z |
| Journal and locking mode | `wal` and `normal` |
| The three required tables | the columns the brief names, all present |
The `orchestration_events` diagnostic query costs about 30 ms on the 394 MB
database.
## 1. WAL and the read-only open (the part Sage asked about first)
The core claim holds. In my test, a `mode=ro` reader saw all three rows
while they were still only in the `-wal` file, with the writer attached.
`immutable=1` did worse than "lose the newest messages": with the table
created inside the WAL, it failed with `no such table`. So the brief's ban
on `immutable=1` and on copies is right.
Two of the brief's statements are wrong, though, and one acceptance check
would fail as written.
**W1. `-wal` without `-shm` does not fail. (Blocking.)** I made that case by
copying a SIGKILLed writer's `-wal` without its `-shm`.
- A `mode=ro` open succeeded, read all three rows, and created the missing
`-shm` itself.
- It failed, with SQLite error 14 ("unable to open database file"), only
when the directory was not writable.
- Adding `readonly_shm=1` didn't help. It made both this case and the
clean-stop case fail, and it still created a `-wal`.
So the acceptance line "Each fail-closed case above has its own test,
including … `-wal` without `-shm`" expects a refusal that doesn't happen.
Change it to two tests:
- **`-wal` without `-shm`, directory writable:** the read counts the WAL
rows.
- **`-wal` without `-shm`, directory not writable:** exit 1, naming
`--no-t3`.
Also fix line 142, which lists this case as a refusal.
**W2. The reader can create files in `~/.t3/userdata`. (Blocking as a
claim.)** Line 157 says "The source never writes to the database", and line
160 says the one outside effect is read locks in `-shm`. Line 44 says "no
locks", which contradicts line 160. What actually happens:
- **T3 stopped cleanly:** `-wal` and `-shm` are gone. A `mode=ro` open
creates an empty `-wal` and a 32 KiB `-shm`, and leaves both after it
closes. The main file is untouched, and the read works even with the
directory read-only.
- **`-wal` without `-shm`:** the reader rebuilds the WAL index into a new
`-shm`.
This is standard SQLite behaviour, and T3 opens normally afterwards.
Replace the claim with the accurate one:
- the reader never writes the main database file;
- it may create or update `-wal` and `-shm` beside it, as any SQLite
connection does.
The T3-stopped test should then assert:
- the counts are correct;
- the main file's bytes are unchanged.
**W3. One read transaction, and a busy timeout.** The brief runs several
queries (schema, project, threads, messages, the diagnostic's events) while
T3 writes.
- In autocommit mode, each statement gets its own snapshot. Wrap the whole
read in `BEGIN` … `COMMIT` (a deferred read transaction), so every query
sees one committed state.
- Set `DatabaseSync`'s `timeout` (a few seconds). Then a transient
`SQLITE_BUSY`, for example while T3 runs a checkpoint or WAL recovery,
waits instead of failing Gate F. A `BUSY` after the timeout is exit 1,
like any other open failure.
**W4. Build the URI with `pathToFileURL`, not string concatenation.** In my
test, a string `file:<path>?mode=ro` worked on Node 26.8.1. But a `?`, `#`
or `%` in the home path would break it. `new URL` plus `searchParams` avoids
that.
**The two untested cases, as acceptance checks.** With W1 and W2 applied,
both are proper checks: a fixture can create each state deterministically.
- **Stopped:** close a WAL database cleanly.
- **`-wal` without `-shm`:** SIGKILL a child writer that set
`wal_autocheckpoint=0`, then delete `-shm`. Copying the `-wal` before any
checkpoint also works.
Add one more: the newest message is in the WAL while the writer holds the
database open. The brief has this ("One test opens a database whose newest
message is still in the WAL"). Say that the writer is still attached,
because that is the live-T3 case.
## 2. Thread-to-seat title rule
Checked live:
- The five 2026-09-26 seat threads have `title_state_json.source` set to
`manual`.
- "Darkwing in Claude" and "Dewey in Claude" predate that field (null).
- T3 also generates titles itself: the table has `title_regeneration_*`
columns and `needsRefinement`.
**How the rule can misassign:**
1. **An auto-generated title.** If Jason opens a thread without naming it,
T3 titles it from his first prompt. A title like "Rocko review of the
plan" maps to rocko.
2. **A rename.** Titles are current state. Renaming a thread moves its
whole history to another row.
3. **A seat's thread titled for a topic.** For example, "review: queue"
drops that seat's messages into `t3:unmapped`.
Misassignment never changes the Human total or the human-per-closed ratio.
It only moves counts between rows. It does matter for Gate F, which reads
one seat's row.
**T1. Add a header cross-check. (Required.)** The headers already carry the
answer. Every agent header addressed to a seat thread names the recipient
and that thread's own id. Live, every one agrees with the title rule:
| Thread | Headers addressed to it |
|---|---|
| Sage | 39, all `to: sage` |
| Darkwing | 14, all `to: darkwing` |
| Filbert | 18, all `to: filbert` |
| Dewey | 15, all `to: dewey` |
| Rocko | 15, all `to: rocko` |
Rule: a user message whose header matches 6a, and whose `to:` id is the
message's own thread, must name the seat the title rule gave that thread.
The same goes for an unmapped thread receiving a seat-addressed header. On
a mismatch, the report refuses with exit 1 and names the thread id and both
roles. This uses only message text, not T3 metadata, so it fits the brief's
principle that `origin` decides nothing.
It catches:
- a seat thread renamed to another seat;
- a seat thread renamed to a topic, once any agent writes to it;
- a Jason-started thread auto-titled with a seat name that agents then
address by a different seat.
It doesn't catch a thread that no agent ever writes to. That case can only
add human counts to a seat row, never hide them, so for Gate F it errs
toward a visible failure. Say so in the README.
**T2. List the mapping in the JSON.** Give each seat's T3 thread ids and
titles, and the unmapped thread ids. A reader of a Gate F result can then
see which threads made up the row.
**T3. Test the rule** with these fixtures:
- a same-title thread in another project (the brief has this);
- a seat thread renamed to another seat, which must refuse under T1;
- a title that starts with a seat name but not followed by a space ("Sagebrush"),
which stays unmapped;
- "Researcher", a real directory with no thread today.
## 3. The fail-closed departure
The reasoning is right, and it follows Sage's ruling 1. A missing `.pi`
directory means no Pi seat ran here. A missing or changed T3 database on a
host that uses T3 means the evidence moved, and a zero there would be the
false pass that Gate F exists to catch. Refusing on a missing table or
column, a missing project or more than one project, a bad row, or a symlink
is consistent with how the Pi reader refuses malformed JSONL.
**F1. The existing tests would read the real T3 database. (Blocking.)**
- `tests/ledger.test.mjs` spawns `cli.mjs` with `env: { ...process.env, … }`,
so `HOME` is the developer's.
- With the source on by default, every existing test would open the real
`~/.t3/userdata/state.sqlite`, find no project for the temp fixture root,
and exit 1.
- On a host without T3, they would exit 1 too.
The brief needs one explicit way to point the reader at a fixture:
- either a fixture `HOME` in the spawned environment, or a documented
override such as `--t3-db PATH`;
- an acceptance line saying no test opens the real `~/.t3`. For example,
the tests set `HOME` to a temp dir for every run.
Existing tests that aren't about T3 should pass `--no-t3` or get an empty
fixture database. Say which.
**F2. Symlink check.** The brief checks `state.sqlite` and `~/.t3/userdata`.
Check `~/.t3` as well, because the Pi reader checks every ancestor from the
root down ("Check every source ancestor"). Today all three are real
directories or files.
**F3. Match the repository root exactly.** Compare `workspace_root` byte for
byte with the ledger's root. The CLI gets that root from the realpath of its
own URL. `~/src/mosaic-stack-dev-test` is a compatibility symlink to this
checkout. A T3 project opened through it would not match, and then "no
project row" is the correct refusal. The README should say this.
**F4. Name the blind spot.** Live, there is a T3 project at `/home/jwoltje`
and a deleted one at `/mnt/storage/src`. A thread in either could work on
this repository and would not be counted. The workspace-root rule is still
right, but the README's exclusions should name this case.
**F5. When the diagnostic's table is missing.** The diagnostic (ruling 3)
reads `orchestration_events`. Say what happens if that table or its columns
are missing:
- The report prints the diagnostic as `unknown`, and the counts are
unaffected, because the diagnostic decides nothing.
- Or add the table to the required schema and refuse.
I'd take the first, because a T3 change to internal metadata shouldn't stop
the counts. Either way, the brief should say which.
## 4. The class fix and the counts
The fix is correct and scoped as Sage ruled. HEAD's
`packages/ledger/src/ledger.mjs:81` (tmux) and `:83` (T3) both have
`class=[a-z-]+`. Make both case-insensitive, not only the T3 one. The
acceptance line "an uppercase class counts as agent after the fix and as
human before it" should cover both preambles. One thing the count doesn't
show: the only human-classified message in my thread is Jason's opening
assignment (19:31:07Z). With the fix, all 18 agent headers to Filbert
classify as agent.
## To reach approve
- **W1:** correct the `-wal`-without-`-shm` expectation and the line-142
refusal; split it into writable and non-writable tests.
- **W2:** replace the "never writes" and "no locks" statements with the
accurate effect; the stopped test asserts the main file's bytes are
unchanged.
- **W3:** one read transaction and a busy timeout. **W4:** build the URI
from a URL.
- **T1:** the header cross-check, refusing on a conflict. **T2:** the
mapping in the JSON. **T3:** the extra fixtures.
- **F1:** a fixture path and no test opening the real `~/.t3`. **F2–F5:**
the ancestor symlink check, an exact root match, the blind spot in the
README, and the diagnostic when its table is missing.
- **§4:** the class fix covers both preambles.
Send the revision with its hash and I'll review the delta.
## Correction (Filbert, 2026-09-26, after R2)
W2 said the clean-stop read "works even with the directory read-only". That
was wrong, and Darkwing's measurement is right. My test for that case reused
a database that the previous test had already opened, so its leftover `-wal`
(0 bytes) and `-shm` were still present. It was not a clean stop. I re-ran it
with a truly clean stop (no `-wal`, no `-shm`) in a non-writable directory
on Node 26.8.1 / SQLite 3.53.4. The open fails with errcode 1544, "attempt
to write a readonly database". So a stopped T3 in a non-writable directory
is exit 1, as R2 expects. The rest of W2 stands: the reader never writes the
main file, and it may create `-wal` and `-shm`.
## R2 delta review
Candidate: `docs/plans/2026-09-26_ledger-t3-source.md`, sha256
`e8300cb6abea70819aba7cf10040d19b4d6019b5663c37203209537a5f10ee62`. I
verified Darkwing's delta files against `manifest.sha256`:
- `r1.md` is `08959a05…45a8`, the R1 I reviewed;
- `r1-to-r2.diff` is `aa4740ae…71fc`.
I read R2 in full.
**Verdict: approve** `e8300cb6`. Every finding is answered:
- W1 and W2: the section 1 table, the accurate claim about side files, and
split tests.
- W3 and W4: one transaction, a 5 s timeout, and `pathToFileURL`.
- T1: as asked, plus Darkwing's addition that an unmapped thread can't
receive an own-id header naming a seat.
- T2 and T3: the mapping in the JSON, and the fixtures.
- F1: `--t3-db`, `HOME` set at both spawn sites, and a no-database test.
- F2–F4: done. F5 follows Sage's ruling.
- §4: both preambles.
Three nits, none blocking. Carry them into the build, or fix them in an R3
and I'll confirm the hash:
1. **The measurement is settled.** Lines 71–74 and 279–282 treat the
non-writable clean stop as disputed. I've corrected my review above: it
fails with 1544. State it as agreed. Make the test a plain exit-1
assertion, and drop the "if the build reads there instead" branch.
2. **Real path in the fixture project row.** The fixture's project row
should store `fs.realpathSync(root)`. The CLI takes its root from the
realpath of `cli.mjs`, and a temp directory under a symlinked `/tmp`
would otherwise fail with "no project row".
3. **Say which database was read.** `--t3-db` can point a real Gate F run at
any file. The JSON should record the database path it read and whether it
was the default. The text report should add one line when it wasn't. A
Gate F result then can't come from a fixture without saying so.
+6
View File
@@ -166,3 +166,9 @@ which stay with him. Each item names who decided it and what happened.
committed and pushed as 6c06a6f3. The restart ran at 20:58:03Z: PID committed and pushed as 6c06a6f3. The restart ran at 20:58:03Z: PID
890894 was replaced by 499064, the gateway was READY at 20:58:04Z, and pi 890894 was replaced by 499064, the gateway was READY at 20:58:04Z, and pi
has the SetSpark verbs. Jason's live check comes next. has the SetSpark verbs. Jason's live check comes next.
14. **Gate F brief approved.** Filbert approved R2 (e8300cb6…); his review is
at bb02d8d3…. He corrected one of his own facts: a cleanly stopped T3
database in a read-only directory fails with 1544, as Darkwing measured.
Either way it exits 1. The three nits ride in the build. The JSON records
which database file it read, so a fixture can't pass for Gate F evidence.
Darkwing builds. Filbert reviews the code, and Sage commits.
+310
View File
@@ -0,0 +1,310 @@
# Ledger: a read-only T3 thread source for Table 2 (Gate F brief)
Brief only, no code. Darkwing wrote it on 2026-09-26 at Sage's request, issue
#1506. R1 (sha256 08959a05) went to Filbert, whose review asked for
revisions: `agents/filbert/work/ledger-t3-source-review-2026-09-26.md`, sha256
19dda29a. R2 (sha256 e8300cb6) took every finding and recorded Sage's
rulings on the three open questions. Filbert approved R2 with three nits,
review sha256 bb02d8d3. This is R3, which takes the nits.
## Why
Table 2 counts user messages per seat from `.pi/state/<seat>/sessions/*.jsonl`
only. Development seats now run in T3 on the Claude and Codex harnesses, so
their prompts, Jason's included, never reach a Pi log. Today the Human column
can't see T3 at all, and the zero it shows for T3 seats means "no source", not
"no human prompts". 6a (ef0020ad) taught `messageKind` the T3 header, but no
source the ledger reads contains one. Gate F (QUEUE row 6) passes when
Filbert's item closes with zero human messages from Jason. While the ledger
can't see T3, a zero there proves nothing.
## Where T3 keeps messages
T3 keeps its state in one SQLite database, `~/.t3/userdata/state.sqlite`, in
WAL mode (`state.sqlite-wal` and `state.sqlite-shm` sit beside it). The counts
need three projection tables:
- `projection_projects`: `project_id`, `workspace_root`, `deleted_at`.
- `projection_threads`: `thread_id`, `project_id`, `title`, `archived_at`,
`deleted_at`.
- `projection_thread_messages`: `message_id` (primary key), `thread_id`,
`role` (`user` or `assistant`), `text`, `created_at` (ISO UTC).
The JSON diagnostic reads one more. In `orchestration_events`, each
`thread.message-sent` event carries `metadata_json.origin`. Messages typed in
the T3 app carry an `appVersion` there. Messages sent through T3's API or MCP
tools, which is how seats talk to each other, don't.
The same directory also holds `secrets/`, `clerk-tokens.json` and other
settings files. The reader opens `state.sqlite` and nothing else, and it
selects named columns only, never `*`.
## 1. Reading it, with T3 running or not
The reader uses Node's built-in `node:sqlite` (`DatabaseSync`). That needs no
dependency, and Node 26.8.1 (SQLite 3.53.4) prints no warning for it.
- **URI.** Build it with `pathToFileURL(dbPath)` and set `mode=ro` through
`searchParams`, then pass `readOnly: true`. A `?`, `#` or `%` in the home
path would break a string-built URI.
- **One snapshot.** Run every query, from the schema checks through the
diagnostic, inside one `BEGIN` … `COMMIT`. In autocommit mode each
statement sees its own snapshot while T3 writes between them.
- **Busy timeout.** Set `DatabaseSync`'s `timeout` to 5 s. A transient
`SQLITE_BUSY` during a T3 checkpoint then waits instead of failing. A busy
error after the timeout exits 1 like any open failure.
- **No `immutable=1` and no copy.** Both lose the WAL. Filbert found worse
than lost messages: with a table created inside the WAL, `immutable=1`
fails with `no such table`.
What happens on disk. Filbert and I both tested these in scratch
directories:
| State | Directory writable | Result |
|---|---|---|
| T3 running, writer attached, newest rows only in `-wal` | yes | reads them |
| `-wal` without `-shm` (writer killed, `-shm` removed) | yes | reads the WAL rows and creates `-shm` |
| `-wal` without `-shm` | no | open fails, SQLite 14 |
| T3 stopped cleanly, no `-wal` or `-shm` | yes | reads, then leaves an empty `-wal` and a 32 KiB `-shm` |
| T3 stopped cleanly | no | fails, SQLite 1544 "attempt to write a readonly database" |
In every case the main file's bytes stayed the same. Filbert's first
review said the last case reads. His test had reused a database whose empty
`-wal` and `-shm` were still present. On a true clean stop he also got 1544,
and his review records the correction. A failed open is exit 1.
So the accurate claim: the reader never writes the main database file. Like
any SQLite connection, it may create or update `-wal` and `-shm` beside it
and takes read locks in `-shm`. T3 opens normally afterwards.
## 2. Jason or agent
Reuse the 6a rule. The first line of `text` decides: a T3 header or the tmux
preamble counts as agent, `control-board` as the sender counts as board, and
anything else counts as human. Messages with role `user` count; assistant
messages don't.
The class fix rides in this build (Sage's ruling). HEAD's
`packages/ledger/src/ledger.mjs:81` (tmux) and `:83` (T3) both allow only
`class=[a-z-]+`. Both become case-insensitive. Seats send uppercase classes:
Sage's DECISION, INFO, REVIEW-REQUEST and REVIEW-NOTE, and my own
REVIEW-REQUEST. In this project's threads 16 real agent headers failed on
that alone at 20:54Z. The ms-communications table lists lowercase names, so
the fix follows what seats send, not the table.
Cross-check, read at 2026-09-26T20:54Z for the mosaic-stack project (209
user messages outside imported and deleted threads, every one with its
`thread.message-sent` event). Filbert's later read agreed, plus messages sent
since.
| T3 origin | Header matches 6a | Count |
|---|---|---|
| typed in the app (has `appVersion`) | no | 99 |
| sent through the API (no `appVersion`) | yes | 80 |
| sent through the API | no, uppercase class | 16 |
| sent through the API | no, free-text roles | 14 |
No message typed in the app carries a header, and every API message in this
project carries one of the three forms. The 14 free-text ones are older
Discord Bot thread headers such as `[from: SetSpark coordinator (…) -> to:
Discord Bot (…)]`, written before the guide fixed the format. Sage ruled they
stay as recorded: they count as human, dated 2026-09-17 to 2026-09-22.
The header rule decides. The JSON also carries one diagnostic that feeds no
table or total: user messages the rule calls human that T3 recorded as sent
through the API. That number exposed the class bug and would catch the next
format drift. `origin` is T3's internal metadata, not a documented contract,
so it decides nothing. If `orchestration_events` or a column it needs is
missing, the diagnostic reads `unknown` and the report goes on (Sage's
ruling on F5). Missing tables the counts depend on still exit 1.
## 3. Thread to seat
**Project.** A thread counts for this checkout only if its project's
`workspace_root` equals the ledger's repository root, byte for byte. The CLI
already takes that root from the realpath of its own URL, today
`/mnt/storage/src/mosaic-stack`, project `34050c07`. So a T3 project opened
through the compatibility symlink `~/src/mosaic-stack-dev-test` doesn't
match, and "no project row" is the right refusal. The README says so.
**Title rule.** Thread IDs change whenever Jason starts a new thread for a
seat, so there's no fixed map. T3-AGENT-COMMS.md already names threads after
the seat ("Darkwing", "Sage", "Dewey in Claude"). A thread belongs to seat
`<s>` when `<s>` is a real directory under `agents/` and the lower-cased
title equals `<s>` or starts with `<s>` followed by a space. So "Sagebrush"
stays unmapped. Several threads can map to one seat, and their counts add
up, as several Pi session files already do.
Today that maps Sage, Darkwing, Filbert, Dewey and Rocko (one thread each,
created 2026-09-26, titles set by hand), plus "Darkwing in Claude" (archived)
and "Dewey in Claude". Researcher has a directory and no thread. Three
threads map to no seat. Two are imported and excluded anyway ("FINDINGS.md
review" and "[dragon-lin:darkwing -> …"). The third is "Discord Bot" with 68
user messages: 54 without a header, and the 14 free-text headers.
Titles are current state, and T3 can write them itself. They go wrong three
ways. T3 auto-titles an unnamed thread from Jason's first prompt, so "Rocko
review of the plan" maps to rocko. A rename moves the whole history to
another row. A seat thread titled for a topic drops into `t3:unmapped`.
None of this changes the Human total or the human-per-closed ratio. It only
moves counts between rows, but Gate F reads one seat's row.
**Header cross-check.** The headers already say which seat a thread belongs
to. For every user message whose header matches the fixed 6a rule and whose
`to:` id equals the message's own `thread_id`:
- in a mapped thread, the `to:` role, lower-cased, must equal that thread's
seat;
- in an unmapped thread, the `to:` role must not be a seat name.
A conflict exits 1 and names the thread id, its title and both roles. A
header whose `to:` id is some other thread is not checked. The check reads
message text only, not T3 metadata. In a live read at 21:02Z every header
agreed: all 104 addressed to their own thread carried the full thread id and
named that thread's seat (Sage 40, Darkwing 15, Filbert 18, Dewey 15, Rocko
16).
It catches a seat thread renamed to another seat or to a topic, once any
agent writes to it. It also catches an auto-titled thread that agents
address by a different seat. It misses a thread no agent ever writes to.
Such a thread can only add human counts to a seat's row, never hide them, so
for Gate F it errs toward a visible failure. The README says so.
**Unmapped row.** Unmapped threads go in one Table 2 row, `t3:unmapped`
(Sage's ruling), so their human messages still reach the Human column and
the human-per-closed ratio.
**Mapping in the JSON.** For each seat, the T3 thread ids and titles that
made its row, and the unmapped thread ids and titles. Anyone checking a Gate
F result can then see which threads the row came from.
A seat's row sums its Pi and T3 counts, and the JSON splits them by source.
Nothing is counted twice. Every T3 session today runs on `claudeAgent` or
`codex`, which don't write `.pi/state`, and Filbert found no T3 header in any
Pi log (6a record).
**Excluded,** with the reason stated in the README:
- Imported threads (`thread_id` starting `import:`, events marked
`historyImport`). They are partial copies of Claude Code sessions, not T3
traffic: 55 user messages in two threads here.
- Deleted threads (`deleted_at` set). Across all projects there are 3, with
3 messages. Archived threads count.
- Threads in other T3 projects. Live, there is a project at `/home/jwoltje`
and a deleted one at `/mnt/storage/src`. A thread in either could work on
this repository and would not be counted. The workspace-root rule is still
the right one, but the README names this blind spot.
## 4. What fails closed
The source is on by default (Sage's ruling). `--no-t3` turns it off, and the
report then says T3 was not read. `--t3-db <path>` reads another database
file instead of `~/.t3/userdata/state.sqlite`. It exists for fixtures and
gets the same checks. The JSON records the database path read and whether
it was the default. When it wasn't, the text report adds one line naming the
path, so a Gate F result can't come from a fixture unnoticed.
Each of these refuses the report with exit 1, the code the ledger already
uses for unreadable session evidence. The report never falls back to Pi logs
alone. Where the database is missing or won't open, the message names
`--no-t3`.
- The database is missing or unreadable, or won't open read-only. That
includes a directory that isn't writable when SQLite needs to create
`-shm`, and a busy error after the timeout. The Pi reader treats a missing
`.pi` as no messages, and this departs from it on purpose. A missing Pi
directory means no Pi seats ran here. A missing T3 database on this host
means the path or T3 changed, and a silent zero is the failure Gate F
exists to prevent.
- `~/.t3`, `~/.t3/userdata` or `state.sqlite` is a symlink. With `--t3-db`,
the file and its directory are checked. The Pi reader checks every
ancestor too, but it skips symlinked entries. Skipping one named file
would be another silent zero, so this reader refuses.
- A table or column the counts need is missing. The reader checks `PRAGMA
table_info` and names what's missing. This catches a T3 upgrade that
changes the schema.
- No project row, or more than one non-deleted row, for this repository root.
- A counted row has a bad `role`, non-string `text`, or a `created_at` that
doesn't parse. The Pi reader already refuses malformed JSONL and bad
timestamps the same way.
- A header conflicts with the title mapping (section 3).
The reader never reads other files in `~/.t3`. It passes no message text
beyond `messageKind`, `issueNumbers` and the header's `to:` role and id, the
same rule as for Pi logs.
## 5. Rulings
Sage ruled on the three questions R1 put to Jason, as lead calls:
1. The source is on by default. A missing or unreadable database exits 1,
and the message names `--no-t3`.
2. Unmapped threads get the `t3:unmapped` row.
3. The 14 free-text headers stay as recorded. They show only in the JSON
diagnostic.
Sage also ruled that the class fix rides in this build, and that a missing
diagnostic table reads `unknown` (F5).
## 6. Acceptance for the build
**No test opens the real `~/.t3`.** Both places in
`packages/ledger/tests/ledger.test.mjs` that spawn `cli.mjs` (the shared
`run()` helper and the direct `spawnSync` at line 66) set `HOME` to the
fixture's temp directory. A test that forgets `--t3-db` or `--no-t3` then
finds no database and fails closed. The existing tests aren't about T3. Each
gets an empty fixture database at the fixture `HOME`'s default path, with
one project row for the fixture root. That row stores
`fs.realpathSync(root)`, because the CLI resolves its root through realpath
and a symlinked temp directory would otherwise not match. So they run with the source on, and
their expected rows don't change. One test asserts that a `HOME` with no
database exits 1 and names `--no-t3`.
Fixture databases are built with `node:sqlite` in a temp directory, in WAL
mode:
- seat threads and an unmapped thread; imported, deleted and archived
threads; a message outside the date range;
- all three header forms, with uppercase classes in both the tmux preamble
and the T3 header;
- a thread with the same seat title in another project;
- a seat thread renamed to another seat, with an agent header to its own
id, which exits 1;
- a "Sagebrush" title, which stays unmapped;
- a thread titled "Researcher", which maps to the seat that has no thread
live.
WAL states, each with its own test:
- The newest message is only in `-wal`, with the writer still attached (the
live-T3 case). It is counted.
- T3 stopped: the database closed cleanly with no writer. Counts are
correct, and the main file's bytes are unchanged afterwards.
- `-wal` without `-shm` in a writable directory: made by a child writer with
`wal_autocheckpoint=0` that is SIGKILLed, then `-shm` deleted. The WAL
rows are counted.
- `-wal` without `-shm` in a directory that isn't writable: exit 1, naming
`--no-t3`. Skipped when the tests run as root, where the mode bits don't
bind.
- The stopped case in a directory that isn't writable: exit 1, naming
`--no-t3`. Skipped as root too.
Also:
- Each other fail-closed case in section 4 has its own test, including a
removed schema column and each symlink.
- A missing `orchestration_events` gives `unknown` for the diagnostic and
the same counts.
- The class fix is proven against HEAD's `messageKind`. An uppercase class
in either preamble counts as agent after the fix and as human before it.
- The JSON lists each seat's threads and the unmapped threads, and the
database path with whether it was the default. A `--t3-db` run prints the
path line in the text report, and a default run doesn't.
- A read against the live database gives the counts in this brief, allowing
for messages sent since. It exits 0 with no header conflict.
- The ledger README's counting rules name the new source, both flags, the
mapping rule and the header check, and the exclusions. That includes the
symlinked-checkout case and the other-project blind spot.
- No suite runs the ledger tests, so the BUILD-LOG entry names the test file.
## Not in scope
- Claude Code transcripts (`~/.claude/projects`) and Codex sessions
(`~/.codex/sessions`). T3's database already holds every message T3
delivered, so those files would only duplicate it.
- Any T3 API call, anything that needs T3 running, and any write beyond
SQLite's own `-wal` and `-shm` handling.
- Fixing the two tmux misclassifications Filbert found in 6a.