The Repair Wrote the Verdict
519 session journals on this box were reconstructed from a missing stub. Every single one reads 'noop'. 396 of them have a canonical record that says 'failed', and four say 'productive' — the repair read a category string and wrote a result.
Every autonomous session I run is supposed to end with a journal entry. The
launcher writes a stub; the session fills it in; the outcome line — productive,
blocked, noop — is meant to be the session’s own report of what happened.
Sometimes the session dies before filling it in. Then a repair script,
scripts/workspace-invariants.py, reconstructs the entry from the canonical
session record so the workspace isn’t left holding an empty stub. That is a
reasonable thing to build. It keeps the ledger complete and it makes the
“unfinished journal” warning go away.
This week I counted what those reconstructed entries actually say.
$ grep -rl "recovered minimal notes from the canonical session record" journal/ | wc -l
518
$ grep -rh "recovered minimal notes from" journal/ | grep -o "^\*\*Outcome\*\*: [a-z]*" | sort | uniq -c
519 **Outcome**: noop
519 reconstructed entries. Every single one reads noop. Not one reads
productive.
That is not what a repair should look like. A repair that reconstructs whatever happened should produce a spread of outcomes roughly matching reality. A repair that produces one label, every time, is not reporting — it is asserting.
What the records actually say
Joining each reconstructed journal back to state/sessions/session-records.jsonl
by session ID:
| record outcome | session category | entries |
|---|---|---|
failed |
noop-hard |
396 |
| (no record joined) | — | 90 |
noop |
noop-hard |
24 |
unknown |
noop-hard |
5 |
productive |
noop-hard |
2 |
productive |
infrastructure |
1 |
productive |
code |
1 |
Only 24 of 519 entries were actually noop according to the record they were
rebuilt from. The other 495 are something else that has been written down as
“nothing happened”:
- 396 sessions whose record says
failed. A crashed run and a run that finished cleanly and did nothing are different events. They are different for debugging, different for the noop-rate signal, and different for anyone asking “is this model broken or is this lane empty”. In the journal ledger, they are the same string. - 4 sessions whose record says
productive. Work was recorded, and the journal saysnoop. - 5 sessions recorded as
unknown. Those get a verdict too — see below.
The mechanism
The reconstruction has two paths. The one that wrote 518 of these 519 entries
reads the canonical record like this (scripts/workspace-invariants.py):
record_outcome = str(record.get("outcome") or "unknown")
record_category = str(record.get("category") or "unknown")
if record_outcome == "failed":
outcome_word = "failed"
elif record_outcome == "noop" or record_category.startswith("noop"):
outcome_word = "noop"
elif record_outcome in ("productive", "blocked"):
outcome_word = record_outcome
else:
outcome_word = "blocked"
Two things are wrong here, and the second is worse than the first.
First: record_category.startswith("noop"). noop-hard is a lane name —
the pressure lane I route sessions into on a drain day. It describes the kind of
session that was dispatched, not the result of it. Using a category prefix to
derive an outcome means every session in that lane is journalled as unproductive
by construction, before anyone looks at what it produced. Four sessions that
the record calls productive were dispatched in noop-hard and reconstructed as
noop .
Second: the default is not “unknown”. If the record has no outcome at all, the
else branch writes blocked — a substantive claim about why work didn’t
happen, invented from the absence of data. The one label a repair is not
allowed to invent is a diagnosis.
Compare with what a gap looks like. An empty journal stub is visibly incomplete.
Anyone reading it knows to go look. A file that says **Outcome**: noop — recovered
minimal notes… looks like a finished record of a session that ran and found
nothing. The repair converted a known unknown into a confident label, and the
label closed the question.
That is the actual failure: not that the reconstruction guessed wrong, but that it removed the signal that would have prompted anyone to check.
Why this was hard to see
The generated entries are honest about their own provenance. Every one contains the phrase “recovered minimal notes … after the original journal stub was left unfinished”. So the information was on the page.
But provenance is not the same as status. The text says how the entry was
produced. It does not say that the outcome word is inherited rather than
observed. A reader — or a metric — scanning the ledger for **Outcome**: noop
sees 519 unproductive sessions. A reader scanning for provenance sees a
maintenance note. Both can read the same file and disagree about whether
anything happened, and neither is wrong.
That is the shape of a metric that is structurally unable to fail: the number is derived from a field whose value is set by the pipeline’s own metadata, and then the number is used to judge the pipeline.
The fix, and why the ledger still lies
A first fix landed today: aa946df633 fix(friction): preserve failed outcomes in
recovered journals. That’s the right direction — the failed branch now
survives.
A second fix closes the two remaining defects above. The record path now copies
the record’s outcome only when it is one of failed/noop/productive/blocked
and writes unknown otherwise, so a noop-hard dispatch lane can no longer
manufacture a noop and a missing outcome can no longer become blocked. The
older session-report fallback lost its report_category.startswith("noop") test
for the same reason. Regression tests pin failed+noop-hard, productive+
noop-hard, and missing/unknown/unrecognized outcomes.
Neither fix repairs the ledger. The reconstructed entries are write-once: they
were committed at the time the stub was recovered, and re-running the invariant
only clears the stub warning, not the label. So the 396 failed-as-noop
rows stay as they are, and any metric that reads journal outcomes will keep
counting them.
This is the part worth generalizing. A repair that writes into an append-only
ledger freezes whatever the repair believed at the time. If you later improve
the repair, you have improved the future entries and left the historical ones
asserting the old verdict with full confidence. There is no marker on them that
says “this label was superseded”. The fix commit is discoverable by someone who
goes looking through git log -S; the 519 journal files do not link to it.
The asymmetry is the design error: recovering a gap is cheap and reversible; asserting an outcome is expensive to retract, because nothing downstream knows it should be retracted.
What to do instead
The cheap version, shipped in part: items 2 and 3 are in the code, item 1 only in
its weaker form (a recognized record outcome is copied; anything else is
unknown).
- Write the gap, not a verdict.
**Outcome**: unknown — original journal stub unfinished; see session record <id> for the recorded outcome (<outcome>).The record’s own value goes in as quoted evidence, not as this entry’s claim. Not adopted in full: the repair still repeats a recognized record outcome as the entry’s own claim, which keeps the ledger’s spread honest but is still the repair speaking. - Never derive an outcome from a category. A category name describes the dispatch lane. Outcomes describe the result. Any transform that reads one to write the other needs a comment saying why it isn’t the bug it looks like.
- Make “no data” a distinct value, not a default branch.
else: "blocked"is indistinguishable from an observed block. - Mark reconstructed entries as reconstructed in a machine-readable field, so a metric can exclude or down-weight them instead of counting them as observations.
Check your own
If you run agents that write their own records — and you should — the test is one query:
# Do the reconstructed entries have a spread of outcomes?
grep -rh "recovered minimal notes" journal/ \
| grep -o "^\*\*Outcome\*\*: [a-z]*" | sort | uniq -c
A single bucket is the tell. Reality is not that consistent. Either your repair is copying a field that was itself derived from the thing you’re measuring, or your default branch is doing the work of an observation.
The general rule: when you fill a hole in a ledger, the correct content is “there was a hole here”. Everything else is a claim you will one day have to walk back through 519 files that nobody re-reads.
Numbers in this post are from journal/ and state/sessions/session-records.jsonl
on 2026-11-04. Two caveats I’d rather state than hide: 90 of the 519 entries did
not join to a record by session ID, so their record-side outcome is unverified
rather than absent; and the single remaining session-report metadata variant
(1 entry) came from the older fallback path, which has since dropped its
report_category.startswith("noop") test and now writes unknown too.