Skip to content

feat(autobahn): multi-epoch window for CommitQC/AppQC flow (CON-358) - #3736

Draft
wen-coding wants to merge 84 commits into
mainfrom
wen/autobahn_epoch_trio
Draft

feat(autobahn): multi-epoch window for CommitQC/AppQC flow (CON-358)#3736
wen-coding wants to merge 84 commits into
mainfrom
wen/autobahn_epoch_trio

Conversation

@wen-coding

@wen-coding wen-coding commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Autobahn keeps a sliding Prev|Current EpochDuo window so nodes can finish late AppQC from the previous epoch while tipcuts advance in Current.

Data flow

  1. Steady-state seeding: finishing the last road of epoch N (AdvanceIfNeeded) registers epoch N+1 for the execution leash.
  2. Restart seeding (data only): data.NewStateSetupInitialDuo(commitQCs).
    • Some([First, Next)): seed epochs covering the retained CommitQC range, EnsureDuoAt(Next), then placeholder windowLast+1/+2 (genesis committees only; goes away when committees track execution).
    • None: empty store → {0,1}.
    • Avail/consensus do not seed. Tip into an unseeded epoch → EpochAt/DuoAt hard-fail.
  3. Per-layer windows: avail, data, and consensus each keep their own Prev|Current copy and advance independently as each accepts boundary CommitQCs.
  4. Admission / seal: CommitQCs admit for Current. Sealing the last CommitQC of epoch N (including 0) waits for AppQC covering N (prune leash) and registry N+1 (execution leash) on admit, before insert. Duo slide stays async in runAdvanceEpoch.
  5. Consensus tipcuts (interlocking): AppQC on tipcuts is optional (“may embed”).
    • Attach AppQC only when newer than the prior tipcut’s App (NextOpt).
    • Attached App must be Current or Current−1 (AcceptsAppEpoch); otherwise drop the attachment and keep the CommitQC App (tipcuts may omit App entirely).
    • Tipcuts do not wait for AppQC; the seal leash on CommitQC admission covers missing epoch-N AppQC.
    • Propose/vote in Current; attached AppQC road must be < view index.

Behavior change

  • Out-of-window / future AppQC and AppVotes wait or drop instead of hard-failing.
  • Tipcut propose uses LastAppQC() with soft attach rules above (no tipcut-side AppQC wait).
  • CommitQC sealing epoch N waits on admit until AppQC covers N (including seal of 0) and WaitForDuo(N+1).
  • Consensus no longer looks ahead past Current for new-committee admission before the boundary CommitQC.

Local-state note

Bounding epoch 0 to [0, EpochLength) is a local WAL/BlockDB break for any Autobahn store that previously stamped every road as epoch_index=0 past road 108 000. Autobahn is not on a live network; wipe PersistentStateDir / BlockDB (or start fresh) when upgrading this branch. Label non-app-hash-breaking covers app hash only.

Test plan

  • Unit: epoch registry / SetupInitialDuo / duo leashes / tipcut AppQC optional + in-window attach
  • Unit: avail seal leashes on PushCommitQC/PushAppQC admit; data, consensus, giga
  • CI: Race Detection + Autobahn Upgrade Module

@cursor

cursor Bot commented Jul 10, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes core consensus verification (epochs, tipcuts, AppQC), availability admission/sealing at epoch boundaries, and restart invariants; incorrect leashing or duo sliding could stall or accept invalid QCs.

Overview
Multi-epoch consensus context. Epoch no longer carries genesis first block/timestamp; those live on ViewSpec as GenesisFirstBlock / GenesisTimestamp. RoadRange is now half-open [First, Next). A new EpochDuo (Prev|Current) classifies roads as ready/stale/future and resolves which epoch owns a road for AppQC verification and admission.

Tipcuts and AppQC. ViewSpec uses Epochs EpochDuo instead of a single epoch. AppQC.Verify binds to epoch index and road range under that epoch. Proposal build/verify allows attached App state from Current or Current−1, compares App progress by road index (not only global number), requires AppQC road strictly before the tipcut view, and falls back to the prior CommitQC App when a future AppQC is offered. GlobalBlock gains RoadIndex and LastInCommitQC.

Availability layer. Avail state is split into app/commit/lane/epoch/persistence modules with commitProgress, laneCollection, and lane vote reweighting on epoch advance. PushCommitQC / PushAppVote / PushAppQC use admit-then-verify: wait on the duo window (or soft-drop stale roads), then verify. Sealing the last road of an epoch blocks admit until an AppQC for that epoch exists and the registry has the next epoch (waitSealLeashes); runAdvanceEpoch slides the duo when the CommitQC tip passes the boundary. Restart requires a prune anchor when Current epoch > 0.

Tests and simulators are updated for the new NewEpoch / ViewSpec shapes; ledger blocksim builds AppQC from the prior tipcut like production.

Local upgrade note: bounding epoch 0 to a finite road range is a WAL/BlockDB break for stores that treated all roads as epoch 0—fresh persistent state is required.

Reviewed by Cursor Bugbot for commit a13b120. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedJul 31, 2026, 8:52 PM

@wen-coding wen-coding changed the title refactor(autobahn): replace trioAt() with direct epochTrio cache and remove LatestEpoch() (CON-358) autobahn epoch: introduce EpochTrio so we can handle multiple epochs (CON-358) Jul 10, 2026
Comment thread sei-tendermint/internal/autobahn/consensus/inner.go Outdated
Comment thread sei-tendermint/internal/autobahn/avail/state.go Outdated
Comment thread sei-tendermint/internal/autobahn/avail/state.go Outdated
Comment thread sei-tendermint/internal/autobahn/avail/state.go Outdated
@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.69827% with 222 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.54%. Comparing base (438cc04) to head (7213d04).

Files with missing lines Patch % Lines
sei-tendermint/internal/autobahn/avail/lane.go 75.62% 22 Missing and 17 partials ⚠️
sei-tendermint/internal/autobahn/data/state.go 63.44% 19 Missing and 15 partials ⚠️
sei-tendermint/internal/autobahn/avail/app.go 67.90% 14 Missing and 12 partials ⚠️
...-tendermint/internal/autobahn/avail/persistence.go 83.82% 12 Missing and 10 partials ⚠️
...ei-tendermint/internal/autobahn/consensus/inner.go 43.33% 9 Missing and 8 partials ⚠️
...ei-tendermint/internal/autobahn/consensus/state.go 66.00% 12 Missing and 5 partials ⚠️
...ermint/internal/autobahn/avail/epoch_transition.go 82.75% 9 Missing and 6 partials ⚠️
sei-tendermint/internal/autobahn/avail/inner.go 81.81% 7 Missing and 7 partials ⚠️
sei-tendermint/internal/autobahn/avail/commit.go 80.39% 6 Missing and 4 partials ⚠️
sei-tendermint/autobahn/types/testonly.go 86.00% 4 Missing and 3 partials ⚠️
... and 6 more
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##             main    #3736       +/-   ##
===========================================
+ Coverage   61.29%   76.54%   +15.25%     
===========================================
  Files        2351       89     -2262     
  Lines      197491     7808   -189683     
===========================================
- Hits       121060     5977   -115083     
+ Misses      65579     1363    -64216     
+ Partials    10852      468    -10384     
Flag Coverage Δ
sei-chain ?
sei-chain-pr 76.93% <81.69%> (?)
sei-db 70.41% <ø> (+1.04%) ⬆️
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
sei-db/ledger_db/block/blocksim/block_generator.go 79.46% <100.00%> (+2.23%) ⬆️
sei-tendermint/autobahn/types/app_qc.go 93.75% <100.00%> (+1.75%) ⬆️
sei-tendermint/autobahn/types/block.go 83.33% <ø> (ø)
sei-tendermint/autobahn/types/epoch.go 100.00% <100.00%> (ø)
sei-tendermint/autobahn/types/epoch_duo.go 100.00% <100.00%> (ø)
...-tendermint/internal/autobahn/avail/block_votes.go 100.00% <100.00%> (+9.09%) ⬆️
...dermint/internal/autobahn/avail/commit_progress.go 100.00% <100.00%> (ø)
...dermint/internal/autobahn/avail/lane_collection.go 100.00% <100.00%> (ø)
...endermint/internal/autobahn/avail/subscriptions.go 93.33% <100.00%> (-6.67%) ⬇️
...int/internal/autobahn/consensus/persist/persist.go 78.78% <ø> (ø)
... and 22 more

... and 2279 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

seidroid[bot]
seidroid Bot previously requested changes Jul 10, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The EpochTrio refactor is broad and mechanically clean, but bounding epoch 0 to a finite road range now forces a real epoch transition at road 108,000, and the consensus trio-rotation has an off-by-one that will stall consensus at that boundary; there are also committee-staleness and nil-deref latent defects in the epoch-transition machinery.

Findings: 5 blocking | 4 non-blocking | 4 posted inline

Blockers

  • Cached placeholder committees survive real epoch registration (Codex P1). registry.TrioAt() generates genesis-committee placeholder epochs and inserts them; AddEpoch() later replaces only the map pointer, so EpochTrio values already cached in the avail/consensus/data inners keep pointing at the stale placeholder *Epoch. advanceEpoch() additionally early-returns whenever the road index is still inside Current's range, so it never refreshes a stale Next. Once a future epoch's committee actually differs from genesis, next-epoch vote weighting (avail reweightForNextEpoch / pushVote uses trio.Next.Committee()) and QC verification will use the wrong committee. Consider having AddEpoch mutate the existing epoch object in place, or make advanceEpoch/TrioAt re-fetch Next from the registry rather than trusting the cached pointer.
  • Cursor's second-opinion review (cursor-review.md) and the repo REVIEW_GUIDELINES.md are both empty, so no Cursor findings or repo-specific standards were available to incorporate; Codex's two findings were reviewed and are corroborated above.
  • Missing test coverage for the actual epoch-boundary transition: the new tests (TestInsertQCCrossEpochFallback, TestPushCommitQCCrossEpochFallback, TestAdvanceEpochTrio) only exercise the registry.TrioAt fallback with artificially-constructed trios, and every other test uses SingleEpochTrio. No test drives consensus across a real road-108,000 boundary (committing the last road of epoch e and proposing the first road of epoch e+1), which is exactly where the P0 off-by-one manifests.
  • 2 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Signature verification was moved under the hot inner lock in avail PushBlock and PushVote (previously done outside via VerifyInWindow). Holding s.inner across ed25519/BLS verification serializes all per-lane block/vote pushes. Prefer snapshotting the committee(s) under the lock and verifying outside, as PushCommitQC and data.PushQC already do.
  • avail.PushAppQC verifies against inner.epochTrio directly without the cross-epoch registry.TrioAt fallback that PushCommitQC uses, so an appQC/commitQC whose epoch is outside the cached trio would fail verification; confirm this cannot occur or add the same fallback for consistency.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-tendermint/internal/autobahn/consensus/inner.go Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 sei-tendermint/internal/autobahn/avail/state.go:432-441 — PushBlock/PushVote regressed on two fronts vs. pre-PR: (1) signature verification (Ed25519/BLS) now runs inside s.inner.Lock(), serialising every peer block/vote against PushCommitQC, PushAppVote, ProduceLocalBlock, WaitForLaneQCs, RecvBatch, etc.; the pre-PR code intentionally routed this through registry.VerifyInWindow outside the lock. (2) PushBlock verifies only against inner.epochTrio.Current.Committee() — unlike sibling PushVote which does Current→Next fallback — so signed lane proposals from Next-only validators (whose queues newInner explicitly allocates) get rejected before enqueue. Fix by mirroring the pattern already used in PushCommitQC (state.go:277-287): snapshot the trio under a brief lock, run Verify/VerifySig outside the lock, then re-lock to enqueue.

    Extended reasoning...

    The regression\n\nBefore this PR, PushBlock and PushVote verified signatures via s.data.Registry().VerifyInWindow(...) outside s.inner.Lock(). After this PR (state.go:435-441 and 486-497), both p.Msg().Verify(committee) and the CPU-heavy p.VerifySig(committee) execute inside the for inner, ctrl := range s.inner.Lock() iterator body.\n\ns.inner is utils.Watch[*inner] (state.go:36). Watch.Lock() (libs/utils/mutex.go:214-220) acquires w.ctrl.mu.Lock() — a plain exclusive mutex, not an RWMutex — and holds it for the entire iterator body. Ed25519 verify is order-of-tens-of-microseconds per call; BLS is heavier. That work now serialises against roughly 26 other call sites (PushCommitQC, PushAppQC, PushAppVote, WaitForLaneQCs, ProduceBlock, RecvBatch, the persister loop, fullCommitQC, …) which all take the same mutex.\n\n### Concrete failure scenario\n\nA validator receives sustained peer traffic — say 100 blocks/s from lane producers and 3000 votes/s from replicas across lanes. Each verify is ~50µs of Ed25519 (worse for BLS). Under the new code the mutex is held for roughly (100+3000)×50µs ≈ 155 ms of wall-clock per second across PushBlock/PushVote alone, blocking every consensus wake-up (ctrl.Updated()) and every other s.inner writer during those windows. Tail latency on QC advancement (PushCommitQC), lane-QC construction (laneQC via RecvBatch), and app-vote acceptance (PushAppVote) all spike. PushVote is worse than PushBlock — it may run Verify+VerifySig twice (Current, then Next fallback) inside the same lock scope.\n\n### The correct pattern already lives in this file\n\nPushCommitQC (state.go:277-287) snapshots the trio under a brief lock, runs qc.Verify(trio) OUTSIDE the lock, and then re-enters to store. PushAppVote (state.go:308-319) uses the same pattern to pull the committee out before v.VerifySig. Committee and *Epoch values are immutable (only inner is atomically replaced), so snapshotting Current+Next committees before the verify is safe. Mirroring this pattern in PushBlock/PushVote is a few lines and closes the regression.\n\n### Secondary issue: PushBlock lacks the Next-committee fallback\n\nnewInner (inner.go:76-84) explicitly allocates blocks and votes queues for lanes present only in the Next epoch, with the comment "so that early votes and proposals from validators joining in the next epoch are not rejected." PushVote implements the Current→Next fallback matching that intent (state.go:486-497). PushBlock does not — it verifies only against Current (state.go:435-441). BlockHeader.Verify (block.go:75-80) fails on !c.HasLane(h.lane), so a signed lane proposal from a Next-only validator is rejected before it can be enqueued in the pre-allocated inner.blocks[lane] slot.\n\nStep-by-step: trio at road 0 with Current=ep0 committee {A,B,C,D} and Next=ep1 committee {B,C,D,E} (E is new). newInner allocates inner.blocks[E] and inner.votes[E] via the Next-only lane loop. E signs and pushes a LaneProposal for its lane. PushBlock reaches p.Msg().Verify(currC)BlockHeader.Verify!currC.HasLane(E) → returns "E is not a lane". inner.blocks[E] stays empty; peers whose PushVote fallback accepted votes for that would-be block now hold votes referencing a header that will never exist. When the transition completes and Current advances to ep1, E has to re-propose and any prior votes are useless. Note: Registry.AddEpoch is only invoked from tests today (production always fabricates Next as a placeholder sharing the genesis committee), so this doesn't manifest in production yet — but the plumbing this PR sets up is explicitly for real epoch transitions.\n\n### Suggested fix\n\nBoth issues fold into the same edit — mirror PushCommitQC:\n\ngo\nvar trio types.EpochTrio\nfor inner := range s.inner.Lock() {\n trio = inner.epochTrio\n}\ncurrC, nextC := trio.Current.Committee(), trio.Next.Committee()\nif err := p.Msg().Verify(currC); err != nil {\n if err := p.Msg().Verify(nextC); err != nil {\n return fmt.Errorf("block.Verify(): %w", err)\n }\n}\nif err := p.VerifySig(currC); err != nil {\n if err := p.VerifySig(nextC); err != nil {\n return fmt.Errorf("block.VerifySig(): %w", err)\n }\n}\nfor inner, ctrl := range s.inner.Lock() {\n // ... existing enqueue logic ...\n}\n\n\nSame shape for PushVote. This restores the pre-PR concurrency behaviour and gives PushBlock the Next-fallback that newInner was built to support.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Well-structured infrastructure PR that introduces the EpochTrio abstraction and fixes the live-path epoch advancement / restart-trio derivation; the security-critical QC verification correctly binds epoch_index ↔ committee ↔ road-range, and I found no reachable-today blockers. The two second-opinion findings are either about not-yet-reachable future work or contained by downstream checks, so they land as non-blocking notes.

Findings: 0 blocking | 4 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's second-opinion review file (cursor-review.md) is empty — that pass produced no output. Codex produced two findings, both incorporated below.
  • Codex (High): the EpochTrio caching design means a cached trio retains placeholder (genesis-committee) epochs generated by Registry.TrioAt, and reweightForNextEpoch/advanceEpoch never create new per-lane queues (blocks/votes/nextBlockToPersist/persistedBlockStart) for validators that only join in a later committee — so once real committee changes exist, new members' lanes would be permanently rejected with ErrBadLane. This is a genuine limitation but is NOT reachable in this PR: Registry.AddEpoch is defined but never called anywhere in production (confirmed by grep), so all epochs currently resolve to the genesis committee placeholder, and inner.go:14-18 already documents this exact gap as an explicit TODO. Recommend ensuring that follow-up (dynamic committee wiring) adds lane-queue creation on epoch transition and the avail runPersist cross-epoch-lane union (state.go:769-771 TODO) before AddEpoch is ever invoked in production.
  • avail.PushVote (state.go:497-531) verifies the LaneVote message and its signature against independently-chosen committees (message may validate against Current while the signature validates against Next), unlike PushBlock (state.go:443-453) which pins a single committee for both checks. Exploitability is limited today because currC==nextC in practice (no committee changes yet) and blockVotes.pushVote / inner.laneQC re-check membership via per-epoch weights, so no forged current-epoch LaneQC is possible; still worth tightening for consistency once committees can differ.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

@seidroid
seidroid Bot dismissed their stale review July 10, 2026 03:37

Superseded: latest AI review found no blocking issues.

seidroid[bot]
seidroid Bot previously requested changes Jul 10, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A large, well-tested refactor replacing single-epoch *Epoch lookups with an EpochTrio/EpochTrioCursor abstraction across consensus/data/avail/p2p. The mechanics are sound for the current fixed-committee case, but the newly-added epoch-transition machinery has two latent correctness gaps (surfaced by Codex) that will bite once membership-changing transitions are actually wired up, plus a lock-scope regression in avail PushVote.

Findings: 1 blocking | 6 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Performance/concurrency regression in avail/state.go PushVote: signature verification (vote.Msg().Verify and vote.VerifySig) now runs while holding s.inner.Lock(). The previous code verified outside the lock via Registry().VerifyInWindow(...). PushBlock in the same file still verifies outside the lock and only takes the trio under the lock — PushVote should follow the same pattern to avoid serializing expensive signature checks under the state mutex.
  • cursor-review.md is empty — the Cursor second-opinion pass produced no output; only Codex's review was available to merge.
  • REVIEW_GUIDELINES.md (pulled from the base branch) is empty, so no repo-specific review standards could be applied.
  • Minor: several exported Verify methods lost their doc comments (BlockHeader.Verify, Block.Verify, LaneVote.Verify, LaneRange.Verify, View.Less, TimeoutVote.Verify, etc.). Not a lint failure under the configured linters, but the removals are incidental to the refactor and slightly reduce documentation.
  • registry.TrioAt inserts generated placeholder epochs into the shared map permanently (epochAt); benign given road indices come from verified QCs, but worth a note that far-future lookups would grow the map.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-tendermint/internal/autobahn/data/state.go Outdated

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Well-structured refactor replacing single-Epoch verification with an EpochTrio/EpochTrioCursor model across the autobahn layers, with good new test coverage. No shipping-blocking bugs: the multi-epoch concerns raised are currently latent because AddEpoch/non-genesis committees have no production callers yet, so all epochs resolve to the genesis-committee placeholder. A few consistency and locking notes are worth addressing.

Findings: 0 blocking | 5 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor second-opinion pass produced no output (cursor-review.md is empty); REVIEW_GUIDELINES.md is also empty, so no repo-specific guidelines were applied.
  • Codex (High) — cached trios retain overridden placeholder epochs: Registry.epochAt inserts placeholders using the genesis committee, and cursors cache the resulting EpochTrio. Once AddEpoch overwrites a registry entry with the real committee, existing cursors keep pointing at the stale placeholder (Next in particular) until they Advance. This is currently latent (no production AddEpoch caller and all committees are the genesis placeholder), but it will produce wrong committees for next-epoch vote weighting / QC verification once real epoch transitions are wired up. Consider a way for cursors/TrioAt to pick up committee updates, or document/enforce the ordering constraint.
  • Registry.TrioAt mutates the map (inserts placeholders) as a side effect of a read-style lookup; over many distinct road indices this grows the map with placeholder epochs that are never GC'd. Minor, but worth a note since it also underlies the staleness issue above.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

@seidroid
seidroid Bot dismissed their stale review July 10, 2026 04:13

Superseded: latest AI review found no blocking issues.

@wen-coding
wen-coding force-pushed the wen/autobahn_epoch_trio branch from c36ec91 to d03b4f5 Compare July 13, 2026 03:42
@wen-coding wen-coding changed the title autobahn epoch: introduce EpochTrio so we can handle multiple epochs (CON-358) feat(autobahn): introduce EpochTrio for multi-epoch verification (CON-358) Jul 13, 2026
@wen-coding wen-coding changed the title feat(autobahn): introduce EpochTrio for multi-epoch verification (CON-358) feat(autobahn): introduce EpochTrio for multi-epoch handling (CON-358) Jul 13, 2026
Comment thread sei-tendermint/internal/autobahn/data/state.go Outdated

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This PR introduces EpochTrio to move autobahn from a single-epoch to a three-epoch (Prev/Current/Next) view, replacing LatestEpoch()/EpochByIndex with road-index-based window lookups and adding epoch seeding/advancement. The core mechanics look sound and are well tested, but two edge cases flagged by Codex are worth addressing: reweightForNextEpoch under-initializes lane queues, and PushQC now rejects out-of-window QCs; both are latent (masked while all epochs share the genesis committee) but will matter once real committee rotation lands.

Findings: 0 blocking | 6 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor produced no output: cursor-review.md is empty. REVIEW_GUIDELINES.md is also empty, so no repo-specific standards were applied. (Codex did produce output — both its findings are incorporated below.)
  • The PR description references APIs/tests that are not present in the diff — advanceEpoch(), data.State.CurrentTrio(), TestInsertQCCrossEpochFallback, TestPushCommitQCCrossEpochFallback, TestAdvanceEpochTrio, and a "cross-epoch verification fallback" in data.PushQC. The actual code exposes EpochTrio() and advances via TrioAt(idx+1) with no fallback. The description appears stale relative to the committed changes; update it so reviewers/maintainers aren't misled about what shipped.
  • Nit: several error messages wrap EpochForRoad failures with the label EpochAt(%d) (e.g. avail/state.go PushCommitQC/PushAppVote/PushAppQC). Rename for accurate diagnostics.
  • Nit: TestPushAppQCPreviousEpoch (avail/state_test.go) builds epochN but discards it (_ = epochN) and never actually advances the state into epoch N, so it does not exercise the "late AppQC after an epoch boundary crossed" scenario its comment describes — it only pushes an epoch N-1 QC/AppQC into a genesis-seeded state.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This PR wires multi-epoch handling via a new EpochTrio abstraction and a seeding-aware Registry; the core logic and test coverage are solid. No present-tense blockers (all epochs still use the genesis committee placeholder), but there are two latent correctness gaps that will bite once real committee rotation is enabled, plus minor notes.

Findings: 0 blocking | 6 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor second-opinion review (cursor-review.md) and the repo REVIEW_GUIDELINES.md were both empty — no Cursor output to merge and no repo-specific guidelines applied.
  • Codex flag (confirmed, latent): fullnodes never seal the registry. SealSeeding() is only called from consensus.NewState (validator path); NewGigaFullnodeRouter builds only a data.State and no consensus/avail state, so on a fullnode seeding stays true forever and EpochAt/TrioAt will auto-generate any missing epoch with the genesis committee, permanently bypassing AdvanceIfNeeded's advancement constraints. Harmless today (genesis committee everywhere) but silently defeats this PR's own safety gate. Consider sealing the registry on the fullnode path (or documenting why it must remain open).
  • After SealSeeding, Registry.EpochAt still acquires the write lock (state.Lock()) for pure reads because it may auto-generate during seeding; consensus hot paths (pushProposal midpoint gate, pushCommitQC) call it per-message. Minor serialization/perf concern worth a fast read path once seeding is sealed.
  • Several wrapped errors are mislabeled: e.g. state.go PushCommitQC/PushAppVote/PushAppQC return fmt.Errorf("EpochAt(%d): ...") while actually calling EpochForRoad. Cosmetic, but confusing in logs.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

seidroid[bot]
seidroid Bot previously requested changes Jul 13, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This PR wires multi-epoch handling into autobahn via a new EpochTrio abstraction and an epoch Registry that seeds epochs on demand. The refactor is clean and well-tested at the unit level, but the epoch-advance path at data/avail has a correctness gap: crossing an epoch boundary requires epoch N+2 to be registered, which the seeding model (AdvanceIfNeeded seeds only N+1) can never satisfy in production, so nodes will error at the end of epoch 0.

Findings: 3 blocking | 5 non-blocking | 3 posted inline

Blockers

  • Root cause of the boundary failure: Registry.AdvanceIfNeeded (registry.go) seeds only currentIdx+1 from an epoch-N AppQC, and an AppQC is fundamentally downstream of its CommitQC (registry.go's own comment states "AppQC never runs ahead of consensus"). But TrioAt requires both Current and Next, so entering epoch N+1 needs epoch N+2 registered. N+2 is only seeded by an AppQC in epoch N+1, which cannot exist before the CommitQC that ends epoch N. The PR description's claim that "AppQC has already been processing roads from epoch N+1" by the midpoint of epoch N contradicts registry.go and the AppQC→CommitQC dependency. Fix options: have AdvanceIfNeeded seed two epochs ahead (N+1 and N+2), or relax the boundary path to tolerate a not-yet-seeded Next. This is also Codex's P0 and should be resolved (or the seeding invariant proven) before merge. Unit tests miss it because SealSeeding is only called in consensus.NewState / the fullnode router, so most tests run in the always-auto-generate seeding phase and never exercise a post-seal boundary crossing.
  • 2 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Cursor's second-opinion pass (cursor-review.md) produced no output; only Codex's review was available to merge.
  • Consider an integration/e2e test that seals seeding and then drives a full epoch-boundary crossing (road EpochLength-1 → EpochLength) through data/avail PushCommitQC/PushQC, which would catch the N+2 seeding gap that unit tests miss.
  • Several EpochForRoad error messages in avail/state.go are mislabeled as EpochAt(%d) / EpochForRoad(%d) inconsistently — harmless but worth aligning the wording with the method actually called.
  • consensus/inner.go pushCommitQC derives the next epoch from qc.Proposal().EpochIndex() rather than i.epoch.EpochIndex(); since the removed explicit epoch-match check is gone, a QC with an inconsistent EpochIndex would be trusted for the lookup. Low risk given QC verification, but worth a defensive assert.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

seidroid[bot]
seidroid Bot previously requested changes Jul 13, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Solid, well-tested groundwork for multi-epoch handling via EpochTrio, but it introduces a liveness-breaking bug for full nodes: they seal registry seeding after only epochs 0/1 and never call AdvanceIfNeeded, so data.State.PushQC's boundary TrioAt(idx+1) fails at the first epoch boundary and full-node sync permanently stalls.

Findings: 3 blocking | 4 non-blocking | 2 posted inline

Blockers

  • Full nodes cannot cross the first epoch boundary (confirmed; also raised by Codex). AdvanceIfNeeded is only invoked from avail/state.go, but full nodes run only data.State (no avail/consensus). NewGigaFullnodeRouter calls SealSeeding() after data.NewState has seeded only epochs 0 and 1, so no further epochs are ever created. When a QC for the last road of epoch 0 arrives, data.State.PushQC calls Registry().TrioAt(idx+1) which requires epoch 2 as Next; it is missing and PushQC returns an error, so the full node stops accepting QCs forever. Fix by either not sealing seeding on full nodes, or having the full-node data path advance/seed epochs from incoming (App)QCs.
  • Missing test coverage for the full-node epoch-boundary crossing. All tests use GenRegistry/GenRegistryAt, which pre-seed 3 epochs, masking the bug above. Add a test that constructs a full-node-style data.State (seeded 0/1 + SealSeeding) and pushes a QC at the last road of epoch 0 to assert the boundary is handled.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Cursor's second-opinion review file (cursor-review.md) was empty — that pass produced no output.
  • Registry.EpochAt now always takes the write lock (r.state.Lock()) even for pure reads after SealSeeding, whereas the old EpochByIndex used RLock. EpochAt/TrioAt are called from consensus (pushCommitQC, midpoint gate) and data/avail restore paths; consider a RLock fast-path for the common post-seal read case to avoid serializing epoch lookups.
  • Several wrapped errors read EpochAt(%d) / EpochForRoad inconsistently with the function actually invoked (e.g. avail/state.go PushCommitQC/PushAppVote/PushAppQC wrap EpochForRoad failures as "EpochAt(%d): %w"). Harmless but misleading in logs — align the message text with the call.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-tendermint/internal/autobahn/avail/state.go Outdated

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A well-structured, heavily-tested refactor that wires the EpochTrio machinery for multi-epoch handling across the data/avail/consensus layers. No blocking correctness issues were found; the main caveat is that committee rotation is still a documented placeholder, so the feature is machinery-only for now.

Findings: 0 blocking | 7 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Codex (P1): every seeded epoch reuses the genesis committee (makeEpoch copies genesis.Committee()), so QC verification, vote weighting, and EVM shard routing remain effectively tied to genesis and no real validator-set change happens across epochs yet. This is intentional and explicitly documented via TODOs in registry.go/giga_router_common.go (real rotation comes with snapshot/execution-layer committee derivation), and it is not a regression (the prior code also used the genesis epoch everywhere), so I do not treat it as a blocker — but reviewers/mergers should be aware this PR introduces the machinery only and the multi-epoch feature is not functionally complete.
  • Cross-epoch consistency: several EpochByIndex(qc.Proposal().EpochIndex()) lookups were replaced with road-index-based resolution, and the removed avail check qc.Proposal().EpochIndex() != inner.epoch.EpochIndex() is gone. The QC's self-reported EpochIndex field is therefore no longer cross-checked against its road index. This is harmless while all committees are identical, but should be revisited when committees actually differ per epoch.
  • The Cursor second-opinion review file (cursor-review.md) was empty — that pass produced no output.
  • Restore path in avail/state.go NewState only handles a single epoch-boundary crossing (if inner.commitQCs.next > startTrio.Current.RoadRange().Last). This is fine given prune anchors are recent, but a restart whose loaded CommitQCs span more than one epoch past the anchor would not fully reweight; worth a comment or assertion documenting the single-boundary assumption.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

@seidroid
seidroid Bot dismissed stale reviews from themself July 13, 2026 05:06

Superseded: latest AI review found no blocking issues.

Comment thread sei-tendermint/internal/autobahn/consensus/inner.go

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Well-structured, heavily-tested scaffolding PR that introduces EpochTrio (Prev/Current/Next) for multi-epoch handling across the autobahn data/avail/consensus layers, replacing the single-epoch LatestEpoch/EpochByIndex model. No blocking correctness or security issues found; a few concurrency/behavioral notes and one disagreement with the Codex finding.

Findings: 0 blocking | 7 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor review (cursor-review.md) is empty — that second-opinion pass produced no output.
  • Design caveat (not a defect, already covered by TODOs): every seeded epoch is created with the genesis committee as a placeholder (makeEpoch / AdvanceIfNeeded), so committee rotation is not actually exercised yet. The reweight/boundary/EvmShard machinery is effectively a no-op until real per-epoch committees land, and restart-from-genesis reconstructs placeholder committees. Worth a tracking issue so the reweight paths get real coverage when rotation is implemented.
  • Codex P1 (data/state.go PushQC verifying provided blocks against the incoming QC's epoch rather than each block's stored-QC epoch): I evaluated this and believe it is NOT a live bug — PushQC's contract is that blocks are the subset finalized by the pushed qc, so they belong to that qc's road/epoch, and the stored-QC match loop only relocates a block when its hash equals a stored header (which for distinct QCs would itself be a safety violation). The asymmetry with PushBlock (which resolves the committee from the stored QC) is a minor smell; recommend a one-line comment documenting the 'provided blocks belong to the pushed QC's epoch' invariant rather than a code change.
  • PushQC now returns an error (previously effectively a no-op) when EpochForRoad can't place a QC that is >1 epoch behind Current. The comment argues such QCs are below the prune cursor, but confirm a lagging/catch-up node re-pushing an old QC can't propagate this error up and terminate the data component.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 sei-tendermint/internal/autobahn/avail/block_votes.go:42-44 — In pushVote, the vote is appended to byHash.votes (block_votes.go:38-39) unconditionally, even when c.Weight(k) == 0 for a Next-epoch-only signer whose block lane exists in both Current and Next. Once weight later crosses LaneQuorum from Current voters, the resulting LaneQC includes the zero-weight signature; peers verifying FullProposal.laneQC against the Current committee reject it because the Next-only signer is not a Current replica, so every leader including this laneQC has its proposal rejected until the boundary reweight clears byHash.votes. Latent today because all epochs use the genesis-committee placeholder (so no Next-only signer exists yet), but a real defect in the membership-transition path this PR is preparing for. Fix: guard both the weight increment and the byHash.votes append on c.Weight(k) != 0reweight in the same file already does this correctly.

    Extended reasoning...

    The bug

    At sei-tendermint/internal/autobahn/avail/block_votes.go:38-39, pushVote performs:

    byHash.weight += c.Weight(k)
    byHash.votes  = append(byHash.votes, vote)

    Both statements run regardless of whether c.Weight(k) is zero. If the signer k is not a member of the passed-in epoch's committee, the weight increment is a harmless no-op, but the vote is still added to the shared byHash.votes slice. Compare with reweight in the same file (lines 57-61) which correctly skips zero-weight signers via if w == 0 { continue } — the same guard is missing here.

    The upstream flow that reaches pushVote with a mismatched committee

    PushVote in state.go uses epochTrio.VerifyInWindow as its outer gate: the vote is accepted if the same committee (Current OR Next) passes both Msg().Verify (lane check) and VerifySig (signer check). A validator E whose key is in Next only, voting on a block whose lane is in both Current ∩ Next, is accepted by VerifyInWindow via the Next branch (Current's VerifySig fails because E is not a Current replica). PushVote then unconditionally calls q.q[h.BlockNumber()].pushVote(s.epochTrio.Load().Current, vote) — passing Current, not the committee that actually accepted the vote.

    Inside pushVote with the Current committee: Current.Weight(E) == 0, so byHash.weight is unchanged, but the vote is still appended to byHash.votes. The block-comment at state.go says "Votes from next-epoch validators are stored (byKey) but contribute zero weight until reweightForNextEpoch runs at the epoch boundary" — but the implementation stores them in both byKey AND byHash.votes, and only the byKey storage is safe.

    Step-by-step failure

    1. Committees: Current = {A, B, C, D}, Next = {A, B, C, D, E} (E is joining).
    2. A block lands on lane A (A ∈ Current ∩ Next).
    3. Validator E signs and broadcasts a LaneVote for that block; peers accept via PushVote.
    4. In pushVote(Current, voteE): Current.Weight(E) = 0. Weight unchanged, but byHash.votes = [voteE].
    5. Validators A, B, C then vote; each call increments byHash.weight (they are Current replicas). At some point byHash.weight >= LaneQuorum and NewLaneQC(byHash.votes) returns a LaneQC whose sigs slice contains {voteA, voteB, voteC, voteE}.
    6. The same corrupted slice is what inner.laneQC returns from avail/inner.go (NewLaneQC(byHash.votes) — no per-sig filtering).
    7. A leader assembles the laneQC into a FullProposal. Peers verify via FullProposal.Verify → laneQC.Verify(c) with c = vs.Epoch.Committee() = Current. verifyQC iterates all sigs and calls Signed.VerifySig(Current) for each; on voteE it returns E is not a replica — the FullProposal is rejected by every peer.
    8. This repeats for every subsequent leader whose laneQC includes voteE until reweightForNextEpoch runs at the epoch boundary and resets byHash.votes — a liveness failure across the transition window.

    Why existing code doesn't prevent it

    • VerifyInWindow is the outer gate but only checks a single committee satisfies the full lambda — it does not enforce that the signer belongs to Current specifically.
    • pushVote's weight increment is safe (adds zero) but its append is not.
    • inner.laneQC and NewLaneQC copy the whole byHash.votes slice without per-sig filtering; they trust the caller to only store valid sigs.
    • reweight (block_votes.go:50-74) already has the correct if w == 0 { continue } guard — this is exactly the invariant pushVote should maintain.

    Impact

    Latent in the current tree: the registry seeding phase creates all placeholder epochs from the genesis committee (Registry.makeEpoch at registry.go:97-110), so Current and Next always contain the same replicas and Current.Weight(k) != 0 for every valid k. Once real committee rotation lands (which is the point of this PR's scaffolding), the first cross-committee lane vote hits this path and starts corrupting laneQCs, breaking liveness at every epoch boundary until the boundary reweight clears state.

    Fix

    Wrap both statements in a weight check, mirroring reweight:

    if w := c.Weight(k); w != 0 {
        byHash.weight += w
        byHash.votes = append(byHash.votes, vote)
    }

    The Next-only signer's vote still lands in byKey, so reweightForNextEpoch will pick it up at the boundary as intended. This makes pushVote and reweight share the same invariant: byHash.votes contains only signatures verifiable against the passed-in committee.

wen-coding and others added 27 commits July 30, 2026 14:47
Lane-vote streams are committee-only; without parking, async epoch entry
drops votes permanently (no p2p retry).

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Log the deciding duo on stale drops, panic on impossible Future after Wait,
print duo ascending, and enforce AcceptsAppEpoch on carried Apps.

Co-authored-by: Cursor <cursoragent@cursor.com>
Match main: each header hash can form a QC from its votes at quorum;
reweight no longer picks a single block-level winner by map order.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…n admit.

Tipcuts may omit App; attach only if newer and in-window. Prune and exec
leashes wait on admitting the last CommitQC of an epoch, not on propose.

Co-authored-by: Cursor <cursoragent@cursor.com>
Exercise block sync without concurrently running consensus against a synthetic QC chain, which could fork lane data and hang replication.

Co-authored-by: Cursor <cursoragent@cursor.com>
Recheck both signer and lane against the live committee after capacity waits so votes verified under the prior epoch are not credited after rotation.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Validate carried app state exactly and keep vote/pruning state coherent across epoch transitions.

Co-authored-by: Cursor <cursoragent@cursor.com>
Separate app, commit, epoch-transition, lane, and persistence logic so the state orchestration remains reviewable.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Group epoch, app, commit, and lane progress so each pipeline stage has explicit state boundaries while preserving existing behavior. Also fix the post-rebase recovery fixture and proposal lint failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
Use aliases for epoch and registry state where wrapper structs no longer provide additional ownership or behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No bugs were found in this review pass. This PR makes deep, correctness-critical changes to Autobahn's epoch-transition and QC admission/sealing logic, and there's an open discussion thread from a human reviewer (pompon0, 2026-07-27) on consensus/inner.go and avail/block_votes.go that doesn't appear to have a visible follow-up commit or reply yet — worth confirming those are resolved before merge.

What was reviewed: the EpochDuo (Prev|Current) admission/backpressure paths in avail (PushCommitQC/PushAppQC/PushAppVote/waitSealLeashes), the AppQC-on-tipcut soft-attach and epoch-window checks in proposal.go, and registry seeding on restart (SetupInitialDuo/AdvanceIfNeeded). A candidate issue about reweight() erasing header data for already-decided blocks in block_votes.go was investigated and ruled out — lane votes are Current-only and reweight only recomputes already-stored Current votes, not a pre-admission path.

Extended reasoning...

Overview

This PR (feat(autobahn): multi-epoch window for CommitQC/AppQC flow) restructures how Autobahn tracks epoch context across consensus, availability, and data layers, replacing a single-epoch model with a Prev|Current EpochDuo sliding window. It touches sei-tendermint/autobahn/types (Epoch/RoadRange/AppQC/Proposal verification), internal/autobahn/avail (a substantial refactor splitting state.go into app/commit/lane/epoch-transition/persistence files, plus new admit-then-verify semantics and seal leashes), internal/autobahn/consensus (ViewSpec/epoch duo plumbing), internal/autobahn/data (epoch-duo-aware PushQC/PushBlock), and internal/autobahn/epoch/registry.go (new placeholder-seeding and WaitForDuo machinery). This is one of the largest and most structurally invasive PRs in this module in recent history.

Security risks

No injection/auth/external-input risks in the traditional sense — this is consensus-internal state machine logic. The real risk category is protocol-correctness: an inverted leash condition, a missed epoch-boundary case, or a race in the admit-then-verify pattern could cause a liveness stall (deadlock waiting on an epoch/AppQC that never arrives) or, worse, accept a QC verified against the wrong committee. The PR description explicitly calls out several such interlocking invariants (seal leashes, duo slide ordering, AppQC road-vs-view checks).

Level of scrutiny

This warrants the highest level of scrutiny this repo produces for non-app-hash-breaking work: it is unreleased/gated (labeled non-app-hash-breaking, and the description states Autobahn is not yet on a live network with an explicit WAL-break warning), but it is core BFT consensus code that will eventually run in production. The PR has already been through multiple rounds of Cursor Bugbot findings (7+), each investigated and either fixed or explained as a false positive by the author with fairly detailed reasoning tied to specific invariants, and one human reviewer (pompon0) engaged substantively on epoch/registry design.

Other factors

The main open item is that pompon0's two comments from 2026-07-27 — one proposing to remove Epoch.FirstBlock/FirstTimestamp in favor of a registry-limited-to-2-epochs invariant, and one pushing back with "I think it is still relevant" on a block_votes.go point the author had marked resolved — have no visible reply or follow-up commit in the timeline I could see. That's a normal, unfinished human review thread rather than a bug this run would report, but it's exactly the kind of unresolved design/correctness disagreement between engineers that should be settled by a human before this lands, not waved through by an automated approval.

wen-coding and others added 2 commits July 31, 2026 13:38
The block-sync livelock fix references rpc.Client in the validator dial loop; keep the import after merging onto the newGigaRouterCommon layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai: skip-review Skips Seidroid AI review triggered via Unified CI. non-app-hash-breaking

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants