Skip to content

Order the ledger by write time, and make every state write honest - #21

Merged
leeovery merged 21 commits into
fix3/e1-semanticsfrom
fix3/e2-ledger
Aug 25, 2026
Merged

Order the ledger by write time, and make every state write honest#21
leeovery merged 21 commits into
fix3/e1-semanticsfrom
fix3/e2-ledger

Conversation

@leeovery

@leeovery leeovery commented Aug 23, 2026

Copy link
Copy Markdown
Owner

The ledger's union-merge story, and exclude hardened.

  • New at write timestamp: last-per-hash resolves by write time, not git merge order; a forward skew ceiling (5 min) stops a future-dated line owning its hash; legacy no-at lines resolve exactly as before
  • mark verifies its own write — re-reads the ledger and raises naming the clock if the heal did not land (backwards-clock protection); the drain reports unlanded writes as a note
  • exclude: path containment before any delete (absolute/traversal/symlink ids refused loudly), whole-batch validation with no half-written state, duplicate ids collapsed and counted, the item's digest removed with it; the claimed-hash veto fails closed when a corpus file will not parse
  • Migration 1 tolerates any junk at (value dropped, line kept); migration 2 resolves last-per-hash by the ledger's own rule and renames <kind>-<hash6>.md outputs when it re-keys identities (ownership proven via the file's recorded url:)

🤖 Generated with Claude Code

leeovery and others added 21 commits August 23, 2026 13:29
Git's union driver emits ours-then-theirs, so the last line of a hash
after a merge is whichever side git appended, not whichever machine
wrote later: a scheduled run's 09:00 blocked could supersede the owner's
10:00 manual, the unit re-drained against the owner's decision, and
compact then deleted the newer line permanently.

Every line now carries `at`, the UTC write instant with sub-second
precision, stamped through ledger.stamp from an injected clock (the two
migration seams gain the same injection). load resolves each hash by
(at, file position) with absent treated as oldest, and compact keeps
whatever load resolves to. No backfill: every writer after this release
stamps one, so an unstamped line necessarily predates every stamped one.

Migration 1's tolerated-keys list gains `at` and its translation carries
the value through — the exact re-apply exposure that list already had
with `capped`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The quarantine parked lines in state/enrichment-ledger.unmigrated.jsonl
and three surfaces told the session to re-add them with `enrich mark` —
a verb that heals a unit, and so structurally cannot re-add a line with
no attributable item. A migration is fully automated and leaves no
residue.

Attribution now tries harder before giving up: an old line that recorded
no item and no path may still have left its output on disk, and outputs
are named enrichment/<item>/<kind>-<hash[:6]>.md, so the tree names the
owner. A prefix landing under two items is dropped rather than guessed.

What survives none of the four attributions is dropped, counted, and
named in the report (first five, then the remainder counted). Safe by
what the ledger is: the corpus is the source of truth and the ledger is
derived work state, so seeding re-raises anything that still matters
through the front door; and the pre-migration ledger is in git history.

Verified against a copy of the real dex-engineering state: all 22
formerly quarantined lines drop, no residue file is created, and a
second apply reports nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`bin/dex exclude` deletes a corpus item and its enrichment permanently
and on the record, but left the item's ledger lines behind. Seeding
builds work from corpus items' URLs, so those lines name work nothing
can ever raise again — they linger forever and read as state.

The verb now drops them in one atomic rewrite after the exclusions
record lands, and states the count. Unparseable lines are kept: a line
the code cannot read is not provably the excluded item's, and a purge
must not become incidental data loss. Git history keeps what goes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`at` breaks ties between concurrent writes; it is not load-bearing state
like item, kind, status or date. Refusing it was defensible while
untranslatable lines went to a quarantine file a human could inspect —
with quarantine gone, that same strictness deletes a line's whole work
history to protect a cosmetic field.

Migration 1 now drops an unreadable `at` (unparseable, or naive and so
uncomparable across machines), names it in the report, and keeps the
line, which then orders by file position exactly as every line did
before the field existed. `ledger.load` keeps its strictness untouched:
a malformed field in a live ledger still refuses to load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`exclude` now takes an item's ledger entries with it, but purges made
before that left theirs behind, and so did items removed by hand. On the
owner's dex-engineering state that is 10 entries — 8 from purges, 2
removed by hand. Migration 4 is the one-time historical cleanup, run
automatically so no session has to notice.

Same disposal argument as migration 1's dropped lines: the corpus is the
source of truth, seeding can never raise this work again, and git
history holds the entries. Same reporting shape: a count and a capped
named list, telling excluded-on-record apart from removed-by-hand.

Two distinctions the sweep is careful about. The ITEM is the test, never
the output file: an entry whose path is missing from disk is a different
finding with a different repair, since the item still exists and the fix
is to re-fetch. And the unit swept is the hash, decided on the line
`load` resolves to, so a hash whose live line names a real item is kept
whole even where an older line named a purged one.

`ledger.resolution_key` becomes public so the migration resolves hashes
by the same rule `load` does instead of reimplementing it — the
migration reads the file tolerantly, because one hand-tampered line must
not abort a sweep, and a sweep must never remove what it cannot read.

The design doc's migration-2 rationale still claimed a purged item's
ledger history stays on file; corrected, and pointed at the sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Migration 1 read the line's stored `path` before the enrichment tree, so
an item renamed since the line was written — same shortid, new slug — was
attributed to a directory that no longer exists. That hands the ghost-item
sweep an item with no corpus file and the work reads as a purge.

The tree answers first now, and where it disagrees with the recorded path
the path moves onto the attributed item too, but only where the file is
demonstrably there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`drop_items` matched per line on the stored `item`, but a work unit is
keyed by URL hash: two corpus items sharing a URL share one entry, which
names only one of them. Excluding that one deleted the unit's whole
history while the other item was still claiming it. dex-engineering has
80 hashes claimed by more than one live corpus item (24 queued, 51 done,
5 dead).

It now judges the hash on the line `load` resolves to and asks the corpus
which work survives, exactly as migration 4 does — scanning after the
deletions, so a purged item cannot claim its own work. The summary states
both counts; `exclude` runs in bulk from the scope-filter pass, so that
line is the owner's only signal.

Lint's docstring said `exclude` "leaves the ledger history standing,
deliberately" — false since this branch started sweeping it. It now names
what an id with no corpus file actually means.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Migration 4 inferred "this item was purged" from "its corpus file is
missing". That inference is ambiguous by construction: a rename, a partial
checkout and a hand deletion are indistinguishable to it, and each guard
added afterwards was a patch over the first guess. It shipped to every
instance forever to tidy a handful of lines in one, and it deleted a real
17-minute transcription on the way.

The residue it existed to clean is created by migration 1 attributing a
line to an item that no longer exists, so the repair belongs there. The
shipping set is 1, 2, 3; every count expectation moves with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An excluded item loses its corpus file and its enrichment directory, but a
done line's recorded path still spells the id — so reading the id back out
of that string attributed finished work to something that no longer exists.
That residue is what the deleted migration 4 was built to sweep; it is
created here, so it is fixed here.

Every derived attribution is now checked against the item's corpus file
before it is accepted, and a line left with none is dropped, counted and
named exactly like a line that never named anything. Rescue effort stays
with items that are still there: a renamed item resolves through the
enrichment tree or the corpus URLs and keeps its work, path repoint
included.

On a copy of real dex-engineering this drops the 8 entries that previously
survived migration 1 to be swept later, leaving a ledger identical to the
four-migration chain's — and the 17-minute transcription under the renamed
item survives, repointed, as it must.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`at` orders a hash's lines, and it is another machine's wall clock: a
jumped one stamped a line 2099 and owned the hash outright. `mark`
reported success, the item's frontmatter was rewritten from the drain and
read healed, `load` went on resolving the stale line, and `compact`
deleted every correct write behind it.

So `at` is the writer's own clock or nothing — `ledger.stamp` is the one
place it is set, migration 2's seeds included — and a stored value more
than FUTURE_SKEW_ALLOWANCE ahead of the reader is read as unstamped: it
sorts oldest, loses to every real write, and `compact` drops it rather
than them. A bad clock costs one line's ordering, never the hash.

Resolution now reads a clock, which stamping still must not: the default
is the wall clock, injectable so the ceiling is testable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Migration 2 read last-per-hash off file position while `load` reads it
off `at`. Harmless on a first sync, where pre-release lines carry no `at`
and both rules fall back to position — but the framework promises
re-application against already-migrated state, and there a union merge
leaving the older line last makes position name a superseded `done` and
requeue work the owner has since ruled on.

`resolution_key` was made public for exactly this and had no migration
caller. It has one now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rule is categorical: a malformed tie-breaker is never grounds for
dropping a line. It held only for a string that fails to parse — `"at":
1755772800` and `"at": null` went through `_expect_str`, raised
untranslatable, and destroyed the whole work history of a line naming a
live corpus item and a real output file.

Any unreadable `at`, whatever its JSON type, now costs the value and is
named in the report; the line keeps its history and orders by file
position, as it did before the field existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`shutil.rmtree(enrichment_dir / item_id)` took the id straight from the
exclusions JSON. `Path / "/abs/path"` discards the left operand and
`Path / "../../x"` climbs, so an id shaped like a path deleted a real
directory outside the instance, reported "removed 0 items (1 already
gone)", exited 0, and recorded itself in exclusions.tsv permanently. The
file is LLM-authored and this runs in bulk, so a wrong id is a realistic
input, not a hypothetical.

An id now names one file in the corpus tree by shape, and both paths it
removes are resolved under the root through `resolve_repo_path` — the
same containment check the `file:` work key already uses, which also
catches an enrichment directory symlinked out of the instance. A refused
id reaches neither the disk nor the TSV.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`exclude` indexed and `.split()` the JSON values with no type check, so a
batch of three whose middle entry was `{"id": 999}` died on a TypeError
`main` does not catch. Entry 1's exclusion record was already in
exclusions.tsv — permanently, in a file `normalize` consults, so its
clusters are never regenerated — while the ledger purge that runs after
the loop never ran, and entry 3 was lost without a word.

Every entry's types are now checked before anything is written or
deleted, and a bad one refuses the batch naming the entry. A refused
batch leaves no trace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The claimed-hash veto is asked of the corpus, and `corpus_owners` skips
an item it cannot parse. That silence fails OPEN toward deletion: exclude
item A while live item B shares one of A's URLs and B's frontmatter is
unparseable, and the shared hash's whole history goes, reported as a
clean drop with no mention that a file could not be read. On one real
instance 80 hashes are claimed by more than one live item.

An unreadable corpus file now holds the whole purge — the design already
keeps an unreadable LEDGER line rather than purge what it cannot judge,
and a file it cannot read is the same call — and the summary names the
count and the repair. The item and its enrichment still go; the re-run
purges once the file parses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`resolve_repo_path` is no longer only the `file:` work key's: exclude
resolves the paths it deletes through it too. Docstring only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The future ceiling bounds a clock that ran ahead. Nothing bounds one that
ran behind — a dead RTC, a container with no NTP, a restored snapshot —
and in that direction `mark` stamps the owner's correction in the past,
it loses to the line it was written to supersede, and `compact` then
deletes it as superseded with no audit trail. Strictly worse than the
forward case, which at least kept the correction, and the verb reported
success throughout: `mark` printed "marked … manual", `status` still read
done, and the heal was gone by the next compaction.

A symmetric floor is not available (there is no reference point for "too
old"), so the verb verifies its own write instead. `mark` re-reads the
ledger, and unless the hash now resolves to the line it just wrote it
fails naming the URL, what the ledger resolves that unit to instead, and
this machine's clock as the likely cause. It fails BEFORE dropping the
superseded outputs and refreshing the item's frontmatter, so nothing acts
on a correction the ledger did not take; the appended line stays as the
audit trail of the attempt.

The same hole one layer up: the enrich-report states outcomes on the
strength of having written them, so `run`, `fetch` and `transcribe` read
their own writes back at `_finish` and name the units whose lines did not
land rather than reporting them as work that landed.

The resolution rule is untouched — the forward ceiling stays exactly as
it is. This is only about a verb telling the truth about its own write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`existing` was read once before the loop and never updated inside it, so
the same id twice in a batch appended two identical rows to
`state/exclusions.tsv` — a permanent record — and the second pass found
the file the first had just deleted, reporting "1 already gone" for an
item nothing had removed. The exclusions file is LLM-authored and runs in
bulk, so the same id twice is a realistic input, and the summary line is
the owner's only signal about what happened.

A duplicate is not an error: excluding an item is idempotent by
construction — the TSV record is exactly what makes a re-run safe — so
the second copy asks for what the first already did, and refusing the
whole batch over a harmless redundancy would send a bulk file back for
hand-editing. The copies collapse to one entry, first occurrence winning,
and the summary states how many it collapsed so the count is never
silently short.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An output is named `<kind>-<hash6>.md`, so the identity re-key left every
moved unit's file on disk named for a hash nothing computes any more.
Nothing in the engine could reach it again: `_drop_superseded_outputs`
builds its candidate names from the NEW hash, so the rerun the migration
seeds landed BESIDE the stale file and the item carried two views of one
unit — the pre-rewrite one (link-stripped, non-thread) and the new one —
both listed in engine-owned `enrichment:` frontmatter, which is derived
from the directory and therefore ADDS the orphan. Every pre-rewrite `x`
entry on a real instance triggers it: twitter.com and x.com both re-key
to x.com/i/status/<id>.

The second leg is silent data loss. A `nocaptions` youtube park on a
/live/, /shorts/, /embed/ or youtu.be URL becomes waiting + transcribe
under migration 1, is re-keyed here, and the transcribe drain then reads
`youtube-<newhash6>.md` — which does not exist — dropping the description
the park had stored. Nothing detects or repairs it afterwards: lint's only
enrichment read is the thread-marker scan, and the engine's two unlink
sites are the superseded-output drop (wrong hash) and `exclude` (the whole
directory).

So the file moves with the hash, following migration 1's precedent for
the outputs its own rewrite renamed — and the entry's stored `path`
follows the file, keyed by the whole path so a superseded line naming it
follows too. Every kind prefix is tried, since a redetected unit's output
sits under the older kind, and each candidate must prove it belongs to
this unit by the URL it records: six hex digits collide, and moving a
neighbour's file would strand THAT unit. A target that already exists —
both old spellings of one post landing on one identity — is refused and
reported rather than overwritten, and an unreadable file is skipped with
why rather than aborting the chain.

`tests/migrations/test_migration_2.py` never created an `enrichment/`
directory, which is why the suite was green; the new cases build one and
cover both legs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`exclude` unlinked the corpus item, rmtree'd `enrichment/<id>/` and purged
the item's ledger entries, and left `state/digests/<id>.md` on disk. Nothing
else ever deletes one: the drain writes digests, the superseded-output drop
only knows enrichment names, and lint's digest check is shape-only, so the
file was never named by anything afterwards. The item's permanent fact
index therefore outlived its permanent exclusion, still feeding query and
wiki, undetected.

The trigger is ordinary: excluding an item that has already been digested
is exactly what re-judging scope at a health check does.

The digest path is resolved through `resolve_repo_path` like the other two,
so a digest symlinked out of the instance refuses the batch rather than
deleting outside it, and the summary counts what went — a line that read
"removed 1 items" while a file it did not mention stayed behind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The design doc still described the state before this branch on three
points, so a reader reconciling it against the code would read each of them
as drift.

Migration 2's section never mentioned the output file, while migration 1's
carries the hazard note for its own half of the name. Migration 2 now
renames `<kind>-<hash6>.md` with the hash and repoints the entry's `path`,
proving ownership by the URL the file records because six hex digits
collide, and refusing a pre-existing target rather than clobbering it.

The `at` section stopped at the forward skew ceiling, which is the only
direction it bounds. A clock set backwards had `mark` reporting success
while the heal was inert and `compact` deleting the correction, so `mark`
now reads the ledger back and raises, and the drain's exit path reports the
same check as a note because a run does real work.

And `exclude` now collapses a duplicate id inside one batch instead of
writing the permanent record twice, with the count on the summary line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@leeovery leeovery changed the title fix3/e2 ledger Order the ledger by write time, and make every state write honest Aug 23, 2026
@leeovery
leeovery marked this pull request as ready for review August 23, 2026 21:48
@leeovery
leeovery merged commit b225057 into main Aug 25, 2026
@leeovery
leeovery deleted the fix3/e2-ledger branch August 25, 2026 11:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant