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]>
10 KiB
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(userorassistant),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=1and 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
-walwithout 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_idstartingimport:, events markedhistoryImport). They are partial copies of Claude Code sessions, not T3 traffic: 55 user messages in two threads here. - Deleted threads (
deleted_atset). 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
-walwithout-shmcase). This differs from the Pi reader, which treats a missing.pias 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_infoand 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-stringtext, or acreated_atthat doesn't parse. The Pi reader already refuses malformed JSONL and bad timestamps the same way. state.sqliteor~/.t3/userdatais 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
- The source is on by default, with
--no-t3to turn it off. The other choice is off by default with--t3to turn it on. I recommend on by default, because Gate F exists to count these messages. - Unmapped threads get a
t3:unmappedrow. The other choice is to drop them. I recommend the row. - 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:sqlitein 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
-walwithout-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.