Ledger skips T3 reasoning summaries and reads issue pages with a
10-page ceiling. Candidate manifest, patch against cc48b124f, mutants
(both killed) and a passing full gate.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
10 KiB
Row 113 (#1607) round 1: the ledger skips T3 reasoning summaries and reads issue pages
Brief: docs/plans/2026-10-14_ledger-reasoning-and-issue-pages.md. Five owned files, no schema change, no live service.
BASE: cc48b124f1 (the row 112 landing). HEAD is now 5b86abe47, which changes only docs/plans/QUEUE.md and docs/plans/queue.json after BASE, so no manifest path moved.
Candidate: build.patch against BASE. candidate-manifest.sha256 lists the five files. I applied the patch to a fresh git archive cc48b124f export and every digest matched.
What changed
T3 reasoning summaries
packages/ledger/src/t3.mjs. In the message loop, right after the line that drops messages outside counted threads:
if (m.role === 'reasoning' && typeof m.message_id === 'string' && m.message_id.startsWith('reasoning:')) { excluded.reasoningMessages++; continue; }
The skip comes before the role, text, created_at and header checks, so a summary can't refuse a run or change a count. t3.excluded gains reasoningMessages beside importedThreads and deletedThreads. Role reasoning with any other id falls through to the role check and still refuses as an unknown role, and so does system or any other role.
Issue pages
packages/ledger/src/ledger.mjs. The old readIssues body becomes readIssuePage(root, range, page, tool). It sends the same query with page=N, applies the same record checks, and also refuses a page with more than 50 rows. Every refusal from it now names the page with "on page N". The error text rules haven't changed: the reason comes from the exit status or a missing tool (ENOENT), never from stderr or the body.
readIssues loops over pages 1 to 10:
- An issue number already seen refuses with exit 2, "duplicate issue number on page N; rerun the report". This also covers a duplicate inside one page, which the old code refused too.
- The first page with fewer than 50 rows ends the read. It returns
{ issues, pages }, with pull requests filtered out as before. - A full page 10 refuses with exit 2: "page 10 is full, so the 10-page ceiling was reached and totals may be incomplete. Narrow --since or use --no-issues".
formatTable prints one more line after the T3 line. It's Issues: N pages read from Gitea, Issues: 1 page read from Gitea for one, or Issues: not read (--no-issues).
packages/ledger/src/cli.mjs. readIssues now returns an object, so cli.mjs has to unpack it. It also sets report.issueSource to { read: true, pages } or { read: false }, which is what formatTable and the JSON read. The brief allows cli.mjs "only if the coverage line or excluded counts print there". The page count is that coverage line, and these three changed lines are all of cli.mjs.
README
packages/ledger/README.md:
- The T3 section has a "Reasoning summaries" bullet. It says what T3 stores since 2026-10-13, that a
reasoningrow with areasoning:id counts nowhere, and that the reader skips it before the text,created_atand header checks.t3.excludedgives the three counts. The refusal list now says rolereasoningwith an id that doesn't startreasoning:still refuses. - "Gitea calls and missing evidence" describes the page loop, the 10-page ceiling, the 50-per-page check, the duplicate refusal, the failed page that names the page but not its output, and the
Issues:line andissueSource. "one-call boundary", "not hidden pagination" and "first page" are gone. - "Queue issue calls" said "the metric call" and "at most 12 calls". It now says metric pages, and at most 21 calls: up to 10 metric pages, 1 open-list call and up to 10 lookups.
unslop-check is clean on the README.
Scope notes
- The brief says the report "shows how many were skipped next to the imported and deleted thread counts". Those two counts are JSON-only (
t3.excluded), and the text table doesn't print them, soreasoningMessagesis JSON-only too. I didn't add a text line for excluded counts, since that would change output the brief didn't ask about. reasoningMessagescounts summaries in counted threads at any date, like the thread counts, which aren't date-bound either. The message query reads every message in a counted thread, so the skip happens before the date filter. The README says "at any date".- The more-than-50 check per page is new. Gitea caps a page at 50, so it can't fire on real data. Without it, a 51-row page would count as full and the loop would read past it.
- A known limit, out of the brief's scope: the duplicate check catches rows that shift down a page during the read, such as a new issue at the top. An issue that leaves the result set mid-read would shift rows up and skip one, and nothing catches that. Updates can't cause it, because
updated_atonly grows and the query is updated-since. Only a deleted issue can. queue-checks.mjshas its own "metric page" detail text andOPEN_LIMIT. Both are out of scope by the brief and unchanged.
Tests
packages/ledger/tests/ledger.test.mjs goes from 78 to 86 tests, all offline. The fake gitea-api.sh now reads page from the URL. With ISSUE_PAGES it serves page N of a JSON array of pages, and [] past the end. With API_FAIL it fails, and with FAIL_PAGE set too it fails only on that page. The T3 addMessage helper takes an optional id.
The old "a full page refuses" test is replaced by these:
| Test | Checks |
|---|---|
| issue pages of 50, 50 and 7 read 107 issues in 3 pages (:146) | 107 closed, issueSource { read: true, pages: 3 }, three calls with page=1, 2 and 3, the text line Issues: 3 pages read from Gitea |
| one short page reads 1 page; --no-issues reads none and says so (:156) | Issues: 1 page read from Gitea. With --no-issues, issueSource { read: false } and Issues: not read (--no-issues) |
| a short page 10 is read; a full page 10 refuses at the ceiling (:162) | 9 full pages and 1 row give 10 pages and 451 issues. 11 full pages exit 2 with "page 10 is full, so the 10-page ceiling was reached" after exactly 10 calls |
| an issue number on two pages refuses (:171) | exit 2, "duplicate issue number on page 2" |
| a failing page 2 refuses the whole read without its stderr (:176) | exit 2, "credential or Gitea request failure on page 2", no stderr text, 2 calls |
| page 2 with a malformed record refuses (:182) | exit 2, "invalid Gitea issue records on page 2" |
| page 2 with more than 50 rows refuses (:182) | the same message |
T3:
- The role table gains "role reasoning without the reasoning: id prefix" (:380). It uses id
x-1and expects "T3 message x-1 in thread t-alice has an unknown role", without the message text. The table's existingsystemrow still passes. - New test "T3: a reasoning summary is skipped before any check and counted as excluded" (:388). It adds a
reasoning:summary:1row with null text andcreated_at"yesterday", either of which refuses a counted row. The run exits 0,t3.excludedis{ importedThreads: 1, deletedThreads: 1, reasoningMessages: 1 }, andseats,pi,issues,totals,t3.seats,t3.unmappedandt3.diagnosticmatch the run without it. A second summary, in another thread, carries a conflicting seat header and#7. That givesreasoningMessages: 2with the same counts again. - The existing excluded assertion now expects
reasoningMessages: 0.
Mutants
mutants.py in this packet. Each mutant swaps one exact line, runs the whole ledger suite (node --test tests/*.test.mjs in packages/ledger), and puts the file back from its .orig copy. Both report restored=True. The run was 16:18:06-16:18:51Z, with the slot from Sage. mutants-run.log has the summary, and mutants/ has the full logs.
| Mutant | Change | Ledger suite | Result |
|---|---|---|---|
| m1 | t3.mjs skips every role reasoning row, with no id prefix check |
85 pass, 1 fail | killed |
| m2 | ledger.mjs returns after page 1 | 80 pass, 6 fail | killed |
m1 fails only "T3: a counted row with role reasoning without the reasoning: id prefix exits 1 without its text", at :386, where the run exits 0 instead of 1. m2 fails the 50/50/7, ceiling, duplicate, failing page 2, malformed page 2 and 51-row page 2 tests. The one-page and --no-issues test passes under m2, as it should, since a one-page read is the same either way.
Gate
gate.sh in this packet. It ran on the candidate with the slot from Sage, 16:18:51-16:24:27Z, on BASE cc48b124f with DOCKER_HOST unset and docker 29.7.2. It runs every package's node suite and every scripts/test-*.sh. GATE_PASSED, exit 0. gate-run.log has the summary, and gate/ has one log per step. The worktree still matched the manifest after the run.
| Step | Passed | Failed |
|---|---|---|
| bus | 148 | 0 |
| business | 67 | 0 |
| cli | 184 | 0 |
| control-board | 124 | 0 |
| conversation | 182 | 0 |
| discord | 178 | 0 |
| harness | 80 | 0 |
| ledger | 86 | 0 |
| mosaic | 69 | 0 |
| queue | 156 | 0 |
| runs | 43 | 0 |
| seat | 120 (0 skipped) | 0 |
| tasks | 92 | 0 |
| webui | 108 | 0 |
| scripts | auth 15, conductor 17, config 24, discord 66, extension-package 18, foundation 44, queue 28 (node 156), release 14, task 98 | 0 |
Ledger goes from 78 to 86. Every other count matches the row 112 gate.
The real bwrap tester case ran and passed. It did not skip. From gate/pkg-seat.log:77:
✔ a confined session that runs tester-cli.mjs is refused as tester, and the same shim outside the sandbox binds (267.061637ms)
None of #258, #261, #262 or #263 came back. The release step made wt_default at 16:23:39Z with no containers, and I removed it at 16:24:30Z.
An earlier run
run1/ holds a full mutant run (16:10:43-16:11:27Z) and a full gate (16:11:31-16:17:08Z) on an earlier candidate. That candidate differs from this one in one README word. "Queue issue calls" said the metric pages don't have an issue "in it", and it now says "on them". Every result in run1/ matches the tables above. I reran both on the final candidate so the evidence names its digests. The first run's wt_default was removed at 16:17:14Z.
Going live
Nothing to do. No live service runs the ledger, so there are no Mos notices and no restart. After landing, Sage reruns the week from 2026-10-04 to 2026-10-10 without --no-issues or --no-t3.