Skip to content

feat(state): sequence branch switches as evaluate, body rollback, then header rewrite#496

Draft
p0mvn wants to merge 1 commit into
zakura-canonical-suffixfrom
zakura/writeguards-canary-v3
Draft

feat(state): sequence branch switches as evaluate, body rollback, then header rewrite#496
p0mvn wants to merge 1 commit into
zakura-canonical-suffixfrom
zakura/writeguards-canary-v3

Conversation

@p0mvn

@p0mvn p0mvn commented Jul 7, 2026

Copy link
Copy Markdown

Motivation

Pillar 1a of REORG_PLAN.md (§2): sequence every branch switch as evaluate → body rollback → header rewrite, with every crash intermediate coherent. On this branch the walk-back (PR #476) already recovers from forks, but its sequencing is inverted relative to the plan's crash-point table: the header suffix is rewritten on disk first, and the stranded non-finalized body suffix is invalidated afterwards, from the driver, as a separate request — leaving a crash window where the header store names branch B while the body chain still extends branch A past the fork. Two further gaps surfaced while pinning the plan's requirements in tests: InvalidateBlock is a ban, not a rollback (BlockPreviouslyInvalidated refuses re-delivery — exactly the stranding hazard §2 warns about if the winning branch evaporates), and invalidating the non-finalized root panics in update_latest_chain_channels (expect on an empty chain set) — a latent crash reachable by the existing driver flow for forks right above the finalized tip.

Base/stack note: this PR targets the fleet branch because the machinery it orchestrates (walk-back, HeaderRangeCommitOutcome.reorged_at) exists only here. The head branch also carries cherry-picks of the main-stacked PRs #491/#493/#494/#495 (tests dropped, per canary convention); those are under review in their own PRs. The reviewable delta of this PR is the last three commits: the StoreIncoherent → ContextMismatch routing (the fleet-branch decision documented in #493), the #495 pick conflict resolution, and the Pillar-1a commit.

Solution

The switch orchestration moves inside the state write worker (commit_header_range in write.rs), which owns both stores, so the sequencing is a straight-line code path rather than a driver round-trip:

  1. Evaluate: batch preparation validates the whole candidate in memory (linkage, checkpoints, contextual difficulty, strictly-greater work gate) and decides the fork point; disk writes are staged only through set_canonical_suffix (fix(state): own zakura header-store mutations with a single suffix-replacement primitive #495). Rejection leaves every store untouched.
  2. Body rollback before the header rewrite reaches disk: if the commit reorgs at height h and the best body chain holds a different block there, the stranded suffix is rolled back via the new NonFinalizedState::rollback_block and the truncated chain is published. The commit outcome now carries reorged_to_hash (the new branch's hash at h) so the worker recognizes the stranded suffix without re-reading state.
  3. Header rewrite: the prepared batch is written (one atomic suffix replacement), then the post-reorg store audit runs (fix(state): own zakura header-store mutations with a single suffix-replacement primitive #495's hook).

Supporting changes:

  • NonFinalizedState::rollback_block — same chain surgery as invalidate_block (extracted into a shared remove_block_and_descendants), but without recording the blocks in the invalidated list. A reorg rollback is a chain-selection outcome, not a verdict: the rolled-back branch revalidates and recommits normally the moment it wins the work race again. Request::InvalidateBlock (operator-facing) keeps its ban-until-reconsidered semantics unchanged.
  • Empty-chain-set guard: rolling back from the non-finalized root empties the chain set; the orchestration publishes the emptied state on the watch channel and skips the tip publish instead of hitting update_latest_chain_channels' expect. (The tip watch keeps the stale tip until the new branch's bodies commit; block sync follows the header store, so re-download reanchors correctly — same behavior as today, minus the panic.)
  • Driver simplification: the post-commit invalidate_reorged_body_suffix call is removed from the commit path (replaced by a reorg-commit log + sync.header.reorg_commits counter). The helper itself stays for the startup/periodic body-suffix reconciliation sweep, which repairs stranding left behind by older binaries — that path is recovery, not switch orchestration.
  • Crash safety / idempotency (§2 crash-point table): the rollback mutates the in-memory non-finalized state; the header rewrite is one atomic batch. A crash between them restarts the node "behind" on a coherent store — the backup restores or resync re-delivers whichever branch currently wins, and the ordinary flow re-runs the switch. Recovery is never a special code path; the startup audit (fix(state): audit and self-repair the zakura header store at startup #494) remains the backstop for any torn state.

Tests

Specifications & References

Follow-up Work

AI Disclosure

  • AI tools were used: Claude Code wrote the orchestration, the rollback variant, tests, and this PR description, directed and reviewed by the author.

PR Checklist

  • The PR title follows conventional commits format
  • This change was discussed with the team beforehand (Pillar 1a of the agreed REORG_PLAN.md)
  • The solution is tested.
  • The documentation and changelogs are up to date (CHANGELOG entry rides the main-stacked PRs; this branch is the fleet canary lineage).

@v12-auditor

v12-auditor Bot commented Jul 7, 2026

Copy link
Copy Markdown

Note

Complete: Audit complete. V12 found one issue worth reviewing.

Open the full results here.

FindingSeverityDetails
F-96651 🟡 Medium
Missing provisional headers can strand the body suffix

Zakura can rewrite the stored header suffix onto a different branch without invalidating the existing non-finalized body suffix when the divergence begins at heights that are merely absent from the provisional header store. In the commit path, replace_from treats missing rows as sufficient to replace headers, but HeaderRangeCommitOutcome only reports a reorg for explicit stored-header conflicts, so commit_header_range skips invalidate_stranded_body_suffix on this path. The underlying view used for that decision, header_by_height, only sees finalized and provisional header rows, not the in-memory NonFinalizedState, so a real body-chain fork can be misclassified as a harmless extension if the Zakura header store lags. The fallback reconciliation path repeats the same assumption by querying zakura_header_hash directly and returning early when the body-tip height has no provisional header row, even though suffix replacement intentionally deletes those rows above the fork. As a result, both the primary rollback path and the intended repair path can miss the same stranded body suffix, leaving header and body state divergent.

Analyzed 13 files, diff 6f8b4ad...c4345ff.

@p0mvn p0mvn added the enhancement New feature or request label Jul 7, 2026
@p0mvn
p0mvn marked this pull request as draft July 7, 2026 02:16
@p0mvn p0mvn changed the title feat(state): sequence branch switches as evaluate, body rollback, then header rewrite (Pillar 1a) feat(state): sequence branch switches as evaluate, body rollback, then header rewrite Jul 7, 2026
@p0mvn
p0mvn changed the base branch from zakura/fallback-keeps-zakura-alive to zakura-canonical-suffix July 7, 2026 17:54
@p0mvn
p0mvn force-pushed the zakura/writeguards-canary-v3 branch from c4345ff to e7bdbb4 Compare July 7, 2026 17:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant