feat: qualified-range guard and provenance red test atop the livelock reproduction - #2605
Merged
Conversation
A version-0 TreeBounds blob records no ironwood tree sizes; the reader manufactures zeroes and discards the version byte, so a block written by a pre-Ironwood scanner at or above Nu6_3 activation reads back indistinguishable from one that legitimately saw an empty ironwood tree. Those laundered zeroes are the same seam testimony the livelock test on this branch shows the healing path trusting. The first test asserts WalletBlock::read rejects a v0 TreeBounds above ironwood activation; it fails on this branch by design. The second asserts a v0 TreeBounds below activation still reads, so the coming fix cannot over-reject legitimately pre-Ironwood blocks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The qualified range is the span of blocks this scanner is designed to handle; it grows only when pepper-sync gains the capability to process a new epoch, never merely because upstream announces one. Without a guard, a stale binary on a chain that has activated an upgrade beyond its qualified range would scan blocks it cannot correctly process, recreating the Ironwood seam-poisoning bug one epoch later. Once per sync session, before any wallet mutation, the engine now fetches the server metadata through a new FetchRequest::LightdInfo variant and checks the tip consensus branch ID against the branches the compiled BranchId enum can name. An unknown or unqualified branch aborts the session with the typed SyncError::TipOutsideQualifiedRange, and a malformed branch string is ServerError::MalformedConsensusBranchId. Two canaries watch the upstream boundary. The exhaustive BranchId match breaks the build the moment upstream stabilises its cfg-gated Nu7 variant, forcing a qualification decision. A canary test fails the moment any upgrade beyond NU6.3 acquires a mainnet activation height, demanding the height-bounded scan planning for the Some case. The ratified vocabulary (Qualified Range, Unqualified, Valid Epochs, Seam Block, Quarantine, Tolerant Read, Live-Lock) is pinned in the zingolib domain glossary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two senses of Unqualified now carry distinct names: an unqualified reading binary is the running software whose Qualified Range does not cover the wallet before it, refusing at wallet read before any scanning exists, and unqualified written data is an entry recorded outside its writer's Qualified Range. The entry also records the ratified heal boundary: truncation to the end of the poisoning writer's greatest understood epoch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR stacks on #2604 (the failing livelock-reproduction test) and, through it, on #2603 (the treestate fix against
dev). It contributes one more red test and the first green piece of the ratified epoch-qualification design, under the vocabulary now pinned inzingolib/CONTEXT.md(## Sync).964f913aepins the on-disk half of the seam-poisoning bug. A version-0TreeBoundsblob carries no ironwood tree sizes, and the tolerant read launders it into well-formed zeroes while discarding the version byte — the only provenance marker distinguishing a pre-Ironwood writer from a writer that legitimately saw an empty ironwood tree. The test asserts that reading such a blob at a height above Ironwood activation must not produce an ordinary block; it fails on this branch by design. A companion test asserts the below-activation read keeps working, so the coming fix cannot over-reject.542fb9c99implements the branch-ID session guard, and is green. Once per sync session, before any wallet mutation, the engine fetches the server metadata through a newFetchRequest::LightdInfovariant and checks the tip's consensus branch ID against the branches the compiledBranchIdenum can name. An unknown or unqualified branch aborts the session with the typedSyncError::TipOutsideQualifiedRange; a malformed branch string isServerError::MalformedConsensusBranchId. Two canaries watch the upstream boundary: the exhaustiveBranchIdmatch breaks the build the moment upstream stabilises its cfg-gatedNu7variant, and a canary test fails the moment any upgrade beyond NU6.3 acquires a mainnet activation height.The qualification matrix
The ratified design classifies every session by two predicates. The reading-binary axis asks whether the running binary's Qualified Range — the span of blocks it is designed to handle — covers the wallet before it. The written axis asks whether every wallet entry was recorded within its own writer's Qualified Range.
SCAN. The ordinary session: every entry sits inside its writer's Qualified Range and the whole wallet sits inside the reading binary's; ranges dispatch normally.
HEAL. The Ironwood scenario, and the only cell where entries are judged. Detection is per-entry — the provenance version byte against the entry's height and the activation table — never a file-level stamp, which only ever names the last writer. Detected entries are quarantined: retained, barred from serving as seam testimony, and preserved byte-for-byte across saves (the write path must round-trip provenance it did not verify, so laundering is impossible). Healing waits for a confirmed connection to an indexer with data at or above the wallet's height, then executes as truncation —
truncate_wallet_dataplustruncate_scan_ranges— to the end of the poisoning writer's greatest understood epoch (the activation height of the writer's first not-understood upgrade, minus one; the minimum over writers if several), followed by rescan. Truncation rather than surgical eviction because the poison propagates into derived state — positions, witnesses, nullifier positions — and a shard tree can only rewind through its checkpoints, never undergo interior surgery. Healing is never repair-in-place: the writer by definition lacked the data to make its records repairable.REFUSE (both written states). An unqualified reading binary cannot locate epoch boundaries it does not know — an Ironwood-era binary reads NU7's activation height as
None— so it judges only the bound, never the data, and the two bottom cells collapse into one behavior: a hard typed refusal at wallet read that leaves the file byte-for-byte untouched. No truncation, no scan, no save; any mutation would either destroy valid testimony or re-stamp the file below the wallet's bound. Healing is a privilege of qualification.Remaining work on this design, in dependency order: the quarantine machinery (provenance-preserving reads and writes, the marked in-memory state, the connected heal by truncation in pre-scan initialisation), retargeting
964f913ae's red assertion from reject-at-read to quarantine-at-read, and the strip-at-reopen door that makes #2604's livelock test pass.🤖 Generated with Claude Code