Skip to content

Discharge the cleanup round's last five debts - #38

Open
leeovery wants to merge 7 commits into
fix3/e17-seamsfrom
fix3/e18-hygiene
Open

Discharge the cleanup round's last five debts#38
leeovery wants to merge 7 commits into
fix3/e17-seamsfrom
fix3/e18-hygiene

Conversation

@leeovery

Copy link
Copy Markdown
Owner

The §16 hygiene items, closing the cleanup round. Observable outcomes proven neutral by a differential review driving both trees — with one real regression the review caught and this branch fixes.

  • Legacy vocabulary spelled once at the ledger boundary; migration 1 derives from it (the loud migration-hint messages byte-identical)
  • default_drivers() replaces the import-time registry: nothing constructs at import, one build per verb, order pinned (web catch-all last)
  • The media GET streams against its 10MB ceiling instead of buffering past it; paths with no ceiling (podcast enclosures, inbox assets) deliberately keep whole-body reads, stated in §16
  • The URL-cap count is maintained beside the entries instead of recounted per admission — a 400-step randomized drive asserts cache == recount throughout
  • One append handle per run replaces the per-line reopen — and the review found the reopen was silently load-bearing: a concurrent compact/exclude/migration swaps the ledger's inode, and a naively held handle orphans every later write (reproduced: report said "done 3", ledger said queued). Fixed by per-append inode revalidation (one stat beats open/write/close), with the property now documented and pinned by three revert-verified regression tests

🤖 Generated with Claude Code

leeovery and others added 7 commits August 24, 2026 12:00
ledger.py's per-word hint tables and migration 1's translation tables
were two spellings of one fact: which words the old engine wrote and
what they became. The boundary now owns them as public RENAMED_KINDS /
RETIRED_STATUSES, migration 1 translates from those same objects, and
its filename-prefix pairs are derived from the kind renames (outputs
are named <kind>-<hash6>.md, so a kind rename IS a prefix rename). The
pinned error messages — the migration-hint text the tests and skills
rely on — are byte-identical.

Bug class made structurally impossible: the copies-drift-apart class
from the review record. Concretely, a future vocabulary retirement
landing in the migration's table but not the boundary's would make the
loud error stop naming the migration that fixes the line (falling
through to the generic unknown-value raise with no sync hint), and one
landing in the boundary's table alone would hint at a sync that fixes
nothing. With one table there is no second copy to forget.

Pinning tests: TestOneVocabularySpelling asserts identity between the
migration's tables and the boundary's, and that the filename prefixes
are exactly the derivation. Verified by mutation: reintroducing local
copies in migration_1 fails both identity pins; hard-coding a stale
prefix tuple fails the derivation pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pipeline/registry.py built DRIVERS as a module-level list the moment
anything imported it — seven driver instances plus a Capabilities
registry, constructed by import order and shared process-wide, against
the no-import-time-state rule the rest of the engine keeps.
default_drivers() now builds that same list on demand and every consumer
calls it at its entry: exclude and lint once per verb, normalize once
per run (threaded through kind_of, which now takes the registry it
detects with), migration 2 once per apply, and run._unit_owners builds
one only for the standing-report callers that hold no RunContext. The
typed literal in build_drivers stays the Protocol-conformance point,
and the order — web catch-all last — is still defined in exactly one
place.

Failure mode made structurally impossible: import-order-dependent
shared state. A module-level registry is one object every caller in the
process aliases — a test or future caller mutating it (reordering,
appending a driver) would silently change detection for every other
caller, and construction at import runs before any entry point can
choose its wiring. With construction behind a call, each caller owns a
fresh list and nothing executes at import.

Pinning tests: TestNoImportTimeState asserts the module holds no
DRIVERS attribute and no module-level built sequence, and that two
default_drivers() calls return distinct lists of distinct instances.
Verified by mutation: reintroducing the module-level literal fails the
first pin; memoizing default_drivers through a global fails the second.
The existing order pins (web last, the seven-kind design order) now run
against default_drivers() and still guard the catch-all's position.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
urllib_transport read every body whole before any size check ran, so
the §7 media ceiling was enforced only after an oversize download had
already been buffered — a 2GB enclosure behind a lying or absent
Content-Length cost 2GB of memory to refuse. The transport now takes
the caller's byte ceiling: with limit set it reads in chunks and stops
one byte past the ceiling, so the caller's len(body) > limit check
still fires while the rest of the body is never requested.
fetch_classified carries the ceiling through, and the media stage
passes MEDIA_MAX_BYTES on its GET. Under-ceiling bodies are
byte-identical; over-ceiling bodies land as the same skipped outcome
with the same reason, now at a bounded cost.

The ceiling rides only the fetches that have one. The enclosure GET
stays whole-body by design (a 150MB episode is the work, cached to
disk), as do inbox's asset download (size known from the API metadata
and verified after; the capture must materialize whole) and the JSON
API reads — every .read( in the HTTP paths was walked and none of the
others has a ceiling to enforce, so imposing one would change outcomes.

Failure mode made structurally impossible: the unbounded buffer between
GET and size check. The check and the read are now one contract — the
ceiling is enforced while the body arrives, so no server response can
make the media stage hold more than ceiling+1 bytes, whatever the
headers claim.

Pinning tests, each verified against its mutation: the chunk-yielding
fake proves an over-ceiling body is read to exactly limit+1 bytes and
no further (fails when the bounded read reverts to response.read(), and
under the remaining=limit off-by-one that would silently truncate an
exactly-oversize body into a "successful" download); a body at the
ceiling arrives byte-identical; and the run-level pin asserts the media
GET carries MEDIA_MAX_BYTES (fails when the wiring is dropped).
FakeTransport honors and records limits the way the real seam does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fetched_count walked every ledger entry per admission to answer the
12-URL cap, so admitting N promotions against an M-entry ledger cost
N*M owner resolutions — quadratic in the item's ledger, paid inside the
one door every unit enters through. The drain now keeps a per-item
table: built lazily from the entries in one pass, updated in record —
the only place entries change — and dropped whenever resolve_owners
runs, because the counts attribute by exactly the answer owner_of
gives and a rename mid-run changes that answer wholesale. The counting
rule itself is now one named predicate (_spends_url_budget) instead of
an inline condition spelled where it is used.

Failure mode made structurally impossible: a maintained count that can
drift from the recount it replaced. The two ways such a cache goes
stale are writes that bypass it and attributions that outlive it;
record is the single mutation door (the table update rides it, before
the superseded line is lost), and the owner map's only writer
invalidates the table in the same breath — so there is no code path
that changes an entry or an attribution without the table following.

Pinning test: TestFetchedCountStaysARecount drives one drain through
seeding, a cap-refused promotion, a --force admission past the bound,
a skip that returns budget, a media line that spends none, and a
rename's re-attribution, asserting after each step that fetched_count
equals an independently spelled full recount. Verified by mutation:
dropping the resolve_owners invalidation, the superseded-line
decrement, or the new-line increment each fails the test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ledger.append opened and closed the file per line, so a drain paid an
open, a buffer, a close and a parent-mkdir for every one of its writes
— hundreds per real run. ledger.Appender now holds one append-mode
handle, opened lazily on the first line; _Drain.record writes through
it and the verbs close it as they finish. ledger.append stays as the
one-shot form for the single-line callers (exclude's landing
correction, fixtures), implemented over the same Appender so there is
one write path.

Durability, stated: open-per-line guaranteed that when append returned
the line had reached the OS (close flushes), making it visible to every
reader — the mark verb re-reads the ledger immediately after appending,
and the run report reads its own writes back — and safe against the
process crashing; it never fsynced, so an OS crash or power loss could
lose the tail. The held handle flushes per line and therefore provides
exactly that same guarantee, no more claimed and no less delivered.
Append mode keeps another writer's interleaved line intact, and the
union-merge file format is byte-identical.

Failure mode made structurally impossible: the per-line reopen storm —
with the handle owned by the drain and opened once by construction,
no ledger write inside a run can reintroduce an open per line.

Pinning tests, each verified against its mutation: byte-identity of a
pooled run of appends against per-line appends (fails under an "a"→"w"
open); every line readable by a fresh load before the handle closes
(fails when the per-line flush is dropped — the buffered lines are
invisible to readers, the durability regression); appends landing at
the file's current end around an interleaved one-shot writer (fails
under both mutations); and the run-level pin that six ledger lines cost
exactly one open-for-append (fails when record reverts to per-line
ledger.append).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
§2 still described the Protocol-conformance point as the module-level
`DRIVERS: list[SourceDriver] = […]` assignment, which the previous
commit removed — the typed literal lives in build_drivers's return now,
constructed on demand. Doc truth only; no code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The held append handle (3a55d42) lost every write that followed a
concurrent ledger rewrite. compact, exclude's drop_items and the
migrations rewrite the file via atomic.write_text — a same-dir temp
file, then one replace, which swaps the inode under the path. The old
per-line reopen re-resolved the path every write, so post-rewrite
appends landed in the new file; the held handle kept the replaced
inode, sending every later line of the run into an orphaned file no
reader sees. At product level: a compact in another terminal during a
long enrich run left the report claiming done while the ledger still
resolved queued, with the read-back misdiagnosing the loss as a
backwards clock. The per-line reopen was load-bearing against atomic
rewrites, and removing it removed that protection.

The fix keeps the handle and restores the property: each append
revalidates that the handle still names the file at the path —
os.fstat on the fd against Path.stat on the path, compared on
(st_dev, st_ino) — and reopens on mismatch, or when the path is
briefly gone. One stat pair per line, still far cheaper than the
open/write/close it replaced; the happy path stays a single open per
run (the one-open pin still passes). The Appender docstring now names
the discovered property so the stat is never optimised away.

The corrected durability contract, honestly stated — 3a55d42's body
claimed equivalence this writer did not have: open-per-line guaranteed
each returned line was with the OS AND written to the file the path
named at that moment; the held handle now guarantees both again
(flush per line; identity revalidated per line). The window that
remains — a replace landing between the check and the write — is the
same window open-per-line always had between its open and its write.
Neither shape fsyncs, as before.

Regression tests, verified by revert (all three fail with the
revalidation stripped back to the naive held handle, and failed on the
defective code before the fix): the seam repro (append, compact,
append twice — every post-compact line durable in the current file and
resolved by load); the path briefly missing (the next append recreates
the file); and the product-level repro (a compact between two record
calls — all three units end done and the report carries no
did-not-take-effect note).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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