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

199 lines
10 KiB
Markdown

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