Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 58 additions & 15 deletions KNOWN-LIMITS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<!-- known-limits:pin v1
This file describes commit 23a213afa39fb8f56d0783f0ba5ab24ad4323320
stamped 2026-10-28
subject: Land chain checkpoints and inclusion proofs (PR 89) at the 2026-10-27 signing sitting; limits 87-89
body-sha256: f037bc54db74e50e3a9711b4549dea8f153686621f70b8b2d0020723699bea43
This file describes commit 295dabc232800e8b42e5b1621486cf3e15ba7c4f
stamped 2026-11-05
subject: KNOWN-LIMITS 76: since view keeps a landed write landed across receipts
body-sha256: a6bb3dd04e3d63bade53a165c9d17201c72f723a7633a0d424d5d5221b430b7a
Re-stamp after updating this log: npm run limits-pin -- --stamp
Divergent checkout? See: npm run limits-pin -- --check
known-limits:pin end -->
Expand Down Expand Up @@ -3058,10 +3058,12 @@ unmerged.

## 76. A refused write was listed as touched, so an attempt read as a change

**Status: PARTLY CLOSED 2026-10-19, branch `lotor-lane/touched-outcome`.
Closed for the parser, the since-view, the text receipt and the live view.
Still OPEN for retcon's charter reconciliation (core) and three smaller
readers; see "Still open" below.** The parser
**Status: PARTLY CLOSED 2026-10-19, branch `lotor-lane/touched-outcome`;
narrowed 2026-11-04, branch `lotor-lane/l76-readers`. Closed for the parser,
the since-view, the text receipt, the live view, MCP `query_receipts` and the
chapters view on Claude Code transcripts. Still OPEN for retcon's charter
reconciliation (core), Codex chapters, and a count this work found; see "Still
open" below.** The parser
now records an Edit/Write attempt and settles it against the tool_result that
answers it. A touched entry from an attempt carries `outcome`: `ok` when the
harness answered with a success, `error` when it answered with an error
Expand All @@ -3087,13 +3089,54 @@ this change carry no `outcome` and render exactly as they did.
items whose `outcome` is `'error'`, keeping ok-is-sticky across receipts),
and `bin/` is wholesale core, so it waits for a signing sitting.
`reconcile.js` itself cannot fix it: it never sees the outcome.
- **MCP `query_receipts` rows** (`src/mcp/server.js`, `summarizeEntry`)
report `touchedCount: p.touched.length`, refused attempts included.
- **The chapters view** (`src/views/chapters.js`) reads Edit/Write paths
straight from the transcript's tool_use and never joins the tool_result.
- **`touchedCount` in the since-view** is still summed per receipt, so with
subsessions "N files touched" overcounts in the same way `touchedRefused`
did. That predates this limit and is left for its own change.
- **Codex chapters.** `chaptersFromCodexRollout` does not settle `apply_patch`
attempts against their output, so a refused patch still reads as a file.

**What the 2026-11-04 narrowing added.** MCP session rows now carry
`touchedRefused`, omitted when no item on the receipt has an `outcome`, because
then the number is unrecorded rather than zero. `touchedCount` still counts
attempts. In the chapters view a chapter gains a `refused` list when it has a
refused path, settled per chapter: a path refused in one chapter and landed in
a later one is refused in the first and not in the second. The since-view's
`touchedCount` is now distinct paths per session. **What to doubt in this part.**
`refused` is left out of the chapters/1 digest on purpose, because digests
already on the chain were taken without it, so the marks are bound only through
the transcript hash and a reader checking the chapter digest alone learns
nothing about them. Distinct paths in the since-view compare strings as the
receipts wrote them, and a receipt from before limit 75's fix can still list one
file under two spellings.

**The since-view's other counts, the same day.** `turns`, `toolCalls` and
`failures` were summed across a session's receipts, and the receipts are
cumulative, so a session with three SessionEnd firings reported each of them
about three times over. They are now the largest value across the session's
receipts. Measured on this machine's chain before choosing, 4,110 sessions: 252
wrote more than one receipt (589 receipts). In none of the 252 did any of the
three counts fall from one receipt to the next, and the largest always equalled
the last. Across the whole chain the sum reported 140,148 tool calls where the
largest reports 124,769, 12.3% over; inside the 252 the median sum was double
the largest and the worst was 5.32 times. Today's traffic barely shows it: in
the seven days measured, 3 of 227 sessions had more than one receipt.
**What to doubt in this part.** The largest value is wrong for a session id
continued in a new transcript: it would under-report, and nothing here detects
that. It was not seen. `session.startedAt` was constant on all 252 except one
placeholder `unknown` id with zero counts, but that is one machine's harness
behaviour, not a promise from it, and a harness that reuses an id would break
this silently. Unexplained and left alone: one session's `touched` list shrank
between receipts (39 paths to 32, 17 gone, none a respelling), so "each receipt
is a superset" held for the counts and not for that one list. The window is
unchanged: a receipt inside the window still reports work done before the
window opened.

**Amended 2026-11-05, the since view against receipts that disagree.** The view
takes `touched` as a union, so a path a later receipt forgets stays listed; the
shrink above costs that view nothing. It did get one order wrong: a path answered
`ok` by one receipt and `error` by a later one read as refused, against the
parser's rule that one landed write is a write. `ok` now holds across receipts,
and only `ok`: `unknown` then `error` still reads as refused. These orders were
run against synthetic receipts. No real session was found showing landed then
refused, and the 39-to-32 session is still not identified or explained, so
whether this order occurs on a real chain is not known.

Found 2026-10-19. Seq 36203 is a gate denial: a session's write to one of its
own hook files was refused. Seq 36204 is that session's receipt, and it lists
Expand Down
16 changes: 15 additions & 1 deletion src/mcp/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,19 @@ function asWritten(p, key) {
return Object.hasOwn(p, key) ? { [key]: p[key] } : {};
}

/**
* KNOWN-LIMITS 76. `touchedCount` counts attempts. This says how many of them
* the harness answered with an error. A receipt written before 2026-10-19
* carries no `outcome` on any item, and for that receipt the number is
* unrecorded rather than zero, so the field is omitted. Absent is not zero.
*/
function touchedRefusedField(touched) {
if (!Array.isArray(touched)) return {};
const items = touched.filter(t => t && typeof t === 'object');
if (!items.some(t => Object.hasOwn(t, 'outcome'))) return {};
return { touchedRefused: items.filter(t => t.outcome === 'error').length };
}

/**
* The chain holds several kinds of entry and they do not share a shape.
*
Expand Down Expand Up @@ -156,7 +169,8 @@ function summarizeEntry(entry) {
...(typeof c.failures === 'number' ? { failures: c.failures } : {}),
...(typeof c.transcriptEntries === 'number'
? { transcriptEntries: c.transcriptEntries } : {}),
...(Array.isArray(p.touched) ? { touchedCount: p.touched.length } : {})
...(Array.isArray(p.touched) ? { touchedCount: p.touched.length } : {}),
...touchedRefusedField(p.touched)
};
}

Expand Down
42 changes: 39 additions & 3 deletions src/views/chapters.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ function makeBook() {
const c = ensure(ts);
if (p && !c.touched.includes(p)) c.touched.push(p);
touch(ts);
return c;
},
fail(ts) { const c = ensure(ts); c.failures++; touch(ts); }
};
Expand Down Expand Up @@ -197,12 +198,31 @@ const FILE_TOOLS = { Edit: 'file_path', Write: 'file_path', NotebookEdit: 'noteb
*/
export function chaptersFromClaudeTranscript(jsonlText) {
const book = makeBook();
// Limit 76. A file tool's tool_use is an attempt, and the tool_result that
// answers it says how it went. attempts: tool_use id -> { chapter, path }.
// settled: chapter -> Map(path -> 'ok' | 'error'). `ok` is sticky within a
// chapter, the same rule the parser applies: one landed write is a write.
const attempts = new Map();
const settled = new Map();
const settle = (id, isError) => {
const a = attempts.get(id);
if (!a) return;
if (!settled.has(a.chapter)) settled.set(a.chapter, new Map());
const m = settled.get(a.chapter);
if (!isError) m.set(a.path, 'ok');
else if (m.get(a.path) !== 'ok') m.set(a.path, 'error');
};
for (const e of parseLines(jsonlText)) {
const msg = e?.message;
if (!msg) continue;
const ts = CLAUDE_TS(e);

if (msg.role === 'user') {
if (Array.isArray(msg.content)) {
for (const item of msg.content) {
if (item?.type === 'tool_result') settle(item.tool_use_id, item.is_error === true);
}
}
if (e.isSidechain !== true) {
const text = claudeUserText(e);
if (text !== null) { book.open(ts, makeTitle(text)); continue; }
Expand All @@ -223,10 +243,19 @@ export function chaptersFromClaudeTranscript(jsonlText) {
book.call(ts, item.name);
const key = FILE_TOOLS[item.name];
const p = key ? item.input?.[key] : undefined;
if (typeof p === 'string' && p) book.path(ts, p);
if (typeof p === 'string' && p) {
const chapter = book.path(ts, p);
if (item.id) attempts.set(item.id, { chapter, path: p });
}
}
}
}
// `refused` appears only on a chapter that has one, so a chapter with no
// refused write keeps exactly the shape it has always had.
for (const [chapter, m] of settled) {
const refused = chapter.touched.filter(p => m.get(p) === 'error');
if (refused.length) chapter.refused = refused;
}
return book.chapters;
}

Expand Down Expand Up @@ -355,10 +384,15 @@ function canonicalize(value) {
* title removed, keys sorted at every depth, arrays in order. Titles are
* operator words and never enter the chain; everything else in a chapter
* is counts, timestamps and paths, which `touched` already puts there.
*
* `refused` is removed too. chapters/1 digests are already on the chain and
* were taken without it, so including it would make every old receipt with a
* refused write read DOES NOT match. The marks are bound only through the
* transcript hash, not through this digest.
*/
export function chaptersCanonical(chapters) {
const rows = (Array.isArray(chapters) ? chapters : []).map(c => {
const { title, ...rest } = c;
const { title, refused, ...rest } = c;
return canonicalize(rest);
});
return JSON.stringify(rows);
Expand Down Expand Up @@ -536,6 +570,7 @@ export function chaptersReport(entries, opts = {}) {
sessions: out,
caveats: [
'Intent is not recorded. A chapter shows which tools ran and which files were named, never why.',
'A file count includes attempts. A refused file is one whose every answered write in that chapter came back as an error; Claude Code transcripts only, Codex rollouts are not settled.',
'A chapter title is the operator\'s own words, quoted. It is not a claim about what was done.',
'Chapters come from transcripts on disk, not from the chain. The chain witnesses the session receipt, not this list.',
'Codex sessions are not on the chain at all. Their chapters are unattested.',
Expand Down Expand Up @@ -597,8 +632,9 @@ export function renderChapters(report) {
} else {
for (const c of s.chapters) {
const files = c.touched.length;
const refused = c.refused?.length ?? 0;
const title = c.title === null ? '(no prompt recorded)' : `"${c.title}"`;
const tail = `(${c.toolCalls} tool call${c.toolCalls === 1 ? '' : 's'}, ${files} file${files === 1 ? '' : 's'}${c.failures ? `, ${c.failures} failed` : ''})`;
const tail = `(${c.toolCalls} tool call${c.toolCalls === 1 ? '' : 's'}, ${files} file${files === 1 ? '' : 's'}${refused ? `, ${refused} refused` : ''}${c.failures ? `, ${c.failures} failed` : ''})`;
L.push(` #${String(c.index).padEnd(3)} ${hhmm(c.startedAt)} ${title} ${tail}`);
}
}
Expand Down
31 changes: 24 additions & 7 deletions src/views/since.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ export function sinceReport(entries, opts = {}) {
return sessions.get(id);
};

// sessionId -> paths some receipt answered with outcome 'ok'. Kept out of the
// session record so it does not reach the report.
const landedBySession = new Map();

const denials = [];
const warns = [];
const egress = [];
Expand Down Expand Up @@ -127,32 +131,45 @@ export function sinceReport(entries, opts = {}) {
s.receiptSeq = e.seq;
s.model = p.session?.model ?? s.model;
const c = p.counts || {};
// A session may write several receipts (subsessions). Accumulate rather
// than overwrite, or the rollup under-reports the busiest sessions.
s.turns = (s.turns ?? 0) + (c.turns ?? 0);
s.toolCalls = (s.toolCalls ?? 0) + (c.toolCalls ?? 0);
s.failures = (s.failures ?? 0) + (c.failures ?? 0);
// A session may write several receipts (subsessions), and each one
// re-parses the whole transcript (src/ingest), so these counts are
// cumulative. A sum counts the same work once per SessionEnd firing.
// Take the largest. KNOWN-LIMITS 76 has the measurement and what this
// would get wrong: a session id continued in a new transcript.
s.turns = Math.max(s.turns ?? 0, c.turns ?? 0);
s.toolCalls = Math.max(s.toolCalls ?? 0, c.toolCalls ?? 0);
s.failures = Math.max(s.failures ?? 0, c.failures ?? 0);
if (Array.isArray(p.touched)) {
s.touchedCount = (s.touchedCount ?? 0) + p.touched.length;
// Keep the paths, not just the count. A count narrows the search to
// a handful of sessions; a path answers the question outright, which
// is the difference between "go and look" and "here it is". This is
// a local view, not the MCP summary surface, so paths are in scope.
for (const t of p.touched) {
const pth = typeof t === 'string' ? t : t?.path;
if (pth && !s.touched.includes(pth)) s.touched.push(pth);
// 'ok' is sticky across receipts, the same rule the parser keeps
// inside one transcript. Only 'ok': an 'unknown' attempt has no
// answer yet, and a later receipt may rightly answer it 'error'.
if (pth && typeof t === 'object' && t?.outcome === 'ok') {
if (!landedBySession.has(id)) landedBySession.set(id, new Set());
landedBySession.get(id).add(pth);
}
// The receipt's own word (limit 76): outcome 'error' means the
// harness returned an error for that attempt. Old receipts carry
// no outcome key and refuse nothing a reader can see.
if (pth && typeof t === 'object' && t?.outcome === 'error') {
if (!s.refusedPaths.includes(pth)) s.refusedPaths.push(pth);
const landed = landedBySession.get(id)?.has(pth) === true;
if (!landed && !s.refusedPaths.includes(pth)) s.refusedPaths.push(pth);
} else if (pth && s.refusedPaths.includes(pth)) {
// A later receipt answering the same path with a landed write
// (any outcome other than 'error', including none) un-refuses
// it: one landed write is a write.
s.refusedPaths.splice(s.refusedPaths.indexOf(pth), 1);
}
}
// DISTINCT paths for the session, for the same reason as below: a
// per-receipt sum counted one file once per SessionEnd firing.
s.touchedCount = s.touched.length;
// DISTINCT refused paths for the session, not a per-receipt sum.
// Every subsession receipt re-parses the whole transcript
// (src/ingest), so one refused write in a session with three
Expand Down
Loading
Loading