Files
stack/agents/darkwing/work/ledger-t3-source/r1.md
T
jason.woltjeandClaude Opus 5.5 ffc22c04c6 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]>
2026-09-26 16:04:56 -05:00

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 (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.