Skip to content

feat: qualified-range guard and provenance red test atop the livelock reproduction - #2605

Merged
dorianvp merged 3 commits into
live_lock_bugfrom
block_written_invalidly
Jul 31, 2026
Merged

feat: qualified-range guard and provenance red test atop the livelock reproduction#2605
dorianvp merged 3 commits into
live_lock_bugfrom
block_written_invalidly

Conversation

@zancas

@zancas zancas commented Jul 31, 2026

Copy link
Copy Markdown
Member

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 in zingolib/CONTEXT.md (## Sync).

964f913ae pins the on-disk half of the seam-poisoning bug. A version-0 TreeBounds blob 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.

542fb9c99 implements the branch-ID session guard, and is green. Once per sync session, before any wallet mutation, the engine fetches the server metadata through a new FetchRequest::LightdInfo variant and checks the tip's 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; 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, 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.

Qualified written Unqualified written
Qualified reading binary SCAN — sync normally HEAL — quarantine at read; truncate and rescan only after a confirmed indexer at or above the wallet's height
Unqualified reading binary REFUSE — typed error, wallet untouched REFUSE — identical; the poison rides untouched until a qualified successor heals it

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_data plus truncate_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

zancas and others added 3 commits July 30, 2026 17:49
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>

@dorianvp dorianvp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utack!

@dorianvp
dorianvp merged commit 95a56f6 into live_lock_bug Jul 31, 2026
19 of 20 checks passed
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.

2 participants