Skip to content

manifest: cold checkpoint + hot append-only journal#16

Merged
Nik7A merged 7 commits into
mainfrom
manifest-split
Jul 17, 2026
Merged

manifest: cold checkpoint + hot append-only journal#16
Nik7A merged 7 commits into
mainfrom
manifest-split

Conversation

@Nik7A

@Nik7A Nik7A commented Jul 17, 2026

Copy link
Copy Markdown
Owner

The sink rewrote and F_FULLFSYNCed the entire manifest.json on every record, and manifest.chains is never pruned. Cheap at one chain per day; not cheap under per-chain-per-run, where bosun measured it: ~90 KB/day vs ~0.5 KB/day, ~179× faster growth, extrapolating to ~22.5 MB rewritten ~1800×/day after 250 busy days. A host cannot fix it — the rewrite is inside the sink, and pruning chains would lie about which chains exist.

Two findings reframed the fix

The manifest is an ATTESTATION, not a cache. The codebase said both. sinks/local_file.py called it "NOT load-bearing for chain integrity"; verify.py treats disagreement with the log as MANIFEST_INTEGRITY — "an integrity break, never a pass" — and files[].sha256 is "the robust anchor". The verifier is right; the docstring was v0.1-era prose the code outgrew. So this change reduces bytes written, never fsync count, never the guarantee.

The head is not the only hot field. files[<today>].sha256 mutates per record exactly like the chain head. Moving only the head out would buy nothing. That is why the shape is a journal, not a per-chain head file: head and file-sha are keyed differently, so a sidecar-per-key needs two fsyncs per record and opens a head/sha inconsistency window — a false MANIFEST_INTEGRITY under attestation semantics. One journal line carries both, published together, one fsync.

Design

  • manifest.json — cold checkpoint. Rewritten only on compaction and construction-time key declaration.
  • manifest.journal — one append-only JSON line per record carrying the resulting state of the chain and file it touched. Resulting state, not deltas, so replay is "apply in order, last wins" — idempotent.
  • Reading = checkpoint + replay. Writing = one journal append + fsync (same fsync count as before, O(1) bytes).
  • Compaction at 1000 lines and on close(): write the checkpoint, fsync, then drop the journal. A crash between is a no-op on replay — the reason entries state results.
  • verify.py now loads checkpoint + journal too, so the sink and verifier never disagree.

Compatibility

Schema bumps manifest.v1.0manifest.v2.0, and here the bump is required (the pubkeys change deliberately avoided one — the cases are asymmetric). A v2 checkpoint's heads lag by up to a compaction interval, so an old reader would take them as authoritative and report a false MANIFEST_INTEGRITY on honest evidence. Refusing to load is the honest failure. So from_dict accepts v1.0 and v2.0 (a v1 manifest has no journal and its heads are authoritative as written), only v2.0 is written, and an unknown version still raises.

Measured

The claim the item made, re-measured: 2000 records across 200 chains → manifest.json written 4 times, not 2000.

Reviewed

Six commits, each TDD with a task review; a whole-branch review on top. The load-bearing test is an equivalence property: verify_tree returns identical verdicts on a journal-backed directory and a compacted one — it passed before any Task-6 change, confirming Tasks 1–5 preserved it. The final review caught one real gap — the journal first encoded the redaction tri-state as a bool, so replaying it latched UNKNOWN → ENABLED, asserting "redaction enabled" where nothing was attested (the exact affirmative lie the tri-state exists to prevent). Fixed by carrying the tri-state and folding it monotonically; reorder-safety and the equivalence case are both pinned by non-vacuous tests.

Deliberately not here

Cross-process append safety (deferred with PR #13); the pre-existing crash window between the JSONL append and the manifest write (neither fixed nor worsened — filed separately); signing the manifest; pruning chains.

560 tests green, mypy --strict and ruff clean.

Nik7A added 7 commits July 17, 2026 18:25
First half of removing the per-record full manifest rewrite. An entry carries the
resulting state of the chain and file a record touched, so replay is "apply in
order, last wins" and applying one twice changes nothing.

That is not tidiness. Compaction writes the checkpoint and only then drops the
journal, so a crash between the two replays lines onto a newer checkpoint. A
delta-shaped entry would double-count there; a stated result cannot.
…d corrupt

One append + fsync per line, so a partial line can only ever be the last one — a
crash mid-write. That one is dropped. A malformed line anywhere else raises,
because silently skipping it would drop an attestation, and dropping evidence
quietly is the failure this library exists to prevent.

_fsync_fd moves here from sinks/local_file.py so both writers share one
definition rather than a copy.
The bump is required here, and the pubkeys change deliberately avoided one — the
cases are asymmetric. pubkeys was additive: an old reader ignoring it still read
correct heads. A v2 checkpoint's heads lag by up to a compaction interval, so an
old reader would take them as authoritative and report MANIFEST_INTEGRITY — a
FALSE integrity break on honest evidence. Refusing to load is the honest failure;
a wrong verdict is not.

So from_dict accepts v1.0 and v2.0 and only v2.0 is written. A v1 manifest has no
journal and its checkpoint is the state, as written.
…nifest

The sink rewrote and F_FULLFSYNCed the entire manifest on every record, and
chains is never pruned. Measured under per-chain-per-run: ~90 KB/day against
~0.5 KB/day, ~179x faster growth, extrapolating to ~22.5 MB rewritten ~1800x a
day after 250 busy days.

Same fsync count, O(1) bytes. The manifest is an attestation — verify reports
MANIFEST_INTEGRITY when it disagrees with the log — so durability is untouched;
only the write size changes.

Construction-time declare_pubkey still writes the checkpoint. It is not a
per-record event.

verify.py's manifest loader read manifest.json raw, so once the sink stopped
rewriting it per record, every verify_tree cross-check saw a stale (empty)
checkpoint instead of current chain/file state. It now replays manifest.journal
on top, same as Manifest.load_or_create. Test fixtures that hand-edit
manifest.json to simulate a lying manifest now collapse checkpoint+journal
first, since a live journal restates a chain/file wholesale and would silently
override the hand-edit otherwise.
Checkpoint first, fsync, then drop the journal. A crash between the two leaves
both, and replay folds stale entries onto a newer checkpoint — a no-op, because
entries state results rather than deltas. The reverse order would lose every
attestation the journal still held.

The line counter is seeded from the journal on construction: a fresh process
(the Claude Code hook path is one per tool call) must not restart the count at
zero and let the journal grow unbounded.
…id otherwise

The module docstring claimed the manifest is "NOT load-bearing for chain
integrity" and exists only so the hook handler can recover the chain head. verify
disagrees in the same codebase: a manifest that contradicts the log is
MANIFEST_INTEGRITY, "an integrity break, never a pass", and files[].sha256 is the
anchor that catches a byte change.

Both could not be true, and which one is decided this whole design: an
attestation cannot have its durability batched away. v0.1-era prose the code
outgrew, in the same shape as the NON-CLAIMS block and __version__.

The equivalence test is the real deliverable: verify_tree returns identical
verdicts against a journal-backed directory and a compacted one.
apply_journal_entry replayed JournalEntry.redaction_disabled (a bool, True
only for DISABLED) through note_redaction_disabled, which latches: from
UNKNOWN, note_redaction_disabled(False) reaches ENABLED. A directory where
nobody ever attested a redaction state read as ENABLED after journal
replay — the exact affirmative lie RedactionState exists to prevent, and a
break from the full-rewrite path this branch replaced, which kept UNKNOWN.

JournalEntry now carries redaction_state as the RedactionState value string
itself. apply_journal_entry folds it in by severity (UNKNOWN < ENABLED <
DISABLED), keeping whichever of the current and replayed state is more
severe, instead of assigning or routing through the bool-taking latch. That
gives both last-wins replay and reorder-safety: an older, less-severe entry
replayed after a newer one can no longer regress the state.

Also corrects the manifest module docstring's now-false claim that the sink
updates manifest.json atomically on every write — this branch made the
per-record path a journal append with manifest.json as a periodic
checkpoint instead.
@Nik7A
Nik7A merged commit cfc5087 into main Jul 17, 2026
1 check passed
@Nik7A
Nik7A deleted the manifest-split branch July 17, 2026 16:52
@Nik7A Nik7A mentioned this pull request Jul 17, 2026
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