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:
@@ -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.
|
||||
Reference in New Issue
Block a user