fix(seinetwork): recover the genesis ceremony when a founding validator is lost mid-plan - #545
Conversation
…or is lost mid-plan A SeiNode deleted while the ceremony plan is active could never come back: reconcileSeiNodes defers creates under PlanInProgress, the ceremony's tasks Get the founding set by name and retry forever, and needsGenesisPlan never rebuilds while Status.Plan is set. reconcilePlan now abandons the active plan when fewer incumbents than replicas remain (PlanInProgress=False and GenesisCeremonyComplete=False, both ValidatorLost, plus a Warning event), letting the child be recreated and the ceremony rebuilt over the whole set. PLT-1242 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
PR SummaryHigh Risk Overview
For minted founding children, surviving nodes are deleted so PVC markers reset and the whole set can be recreated with a fresh ceremony (avoids split genesis from stale sidecar markers). For adopted validators (older than the network), survivors are kept,
Tests add unit coverage for both branches plus an envtest recovery scenario; the sidecar stub gains Reviewed by Cursor Bugbot for commit 27910d0. Bugbot is set up for automated code reviews on this repo. Configure here. |
|
|
||
| // Drive active plan. | ||
| if network.Status.Plan != nil && network.Status.Plan.Phase == seiv1alpha1.TaskPlanActive { | ||
| if validatorLost(network) { |
There was a problem hiding this comment.
blocker — This fires on an active ceremony plan at any task index, but the ceremony is not restartable from the middle.
The sidecar gates both halves of genesis on markers in the node's data PVC: assemble-genesis returns immediately when .sei-sidecar-assemble-done exists (sidecar/tasks/assemble_genesis.go:132), and each node's configure-genesis returns immediately when .sei-sidecar-genesis-done exists (sidecar/tasks/genesis.go:93). The assembler is the lexicographically-first child (cmd/main.go:237-240), and children only reach Running after assemble has published the final genesis — so the whole collect-and-set-peers / await-nodes-running window (the long one) sits after the assemble marker is written.
With replicas=2 and -1 lost during await-nodes-running: the plan is abandoned, -1 is recreated with a fresh identity, the ceremony is rebuilt, and its assemble-genesis hits the marker on the surviving -0 and does nothing. S3 still holds the genesis minted from the dead -1's gentx. The replacement downloads it, marks ready, goes Running; await-nodes-running only checks PhaseRunning, so the plan Completes and GenesisCeremonyComplete latches True. The genesis validator set now contains a consensus key nobody holds — 50% voting power, never >=2/3 — so the chain never produces a block while the SeiNetwork reports healthy. Nothing catches it: the controller consumes no AssembleGenesisResult/genesisHash.
Mirror case if the lost node is the assembler: the fresh -0 reassembles a new genesis while the surviving -1 keeps the old one behind its own .sei-sidecar-genesis-done.
Today's behaviour is a wedge an operator can see; this trades it for a network that claims to be fine and can never produce a block. Scope the guard to the window where restarting is sound (before assemble-genesis completes), or make the rebuild invalidate the survivors' assemble/genesis markers so every founding node re-derives from the new set.
There was a problem hiding this comment.
Agreed, and fixed in ee9e9d4 by taking the second option: the rebuild invalidates the survivors' markers by deleting the survivors. abandonPlanForLostValidator now deletes every remaining founding child alongside clearing the plan; the SeiNode finalizer removes each data PVC (and with it .sei-sidecar-assemble-done / .sei-sidecar-genesis-done), the set is recreated from scratch, and the rebuilt ceremony assembles genesis from the new identities — at any task index, both when the lost node is the assembler and when it is not. Nothing of the chain exists before GenesisCeremonyComplete, so an empty restart is the only convergent outcome.
Also from this: populateIncumbentNodes no longer counts a Terminating child as an incumbent — otherwise the ceremony could be rebuilt over a node still held by its finalizer, which the network then had to abandon again.
On the non-blockers: validatorLost is intentionally scoped by the reconcilePlan structure (only ceremony plans exist at network level); I'll leave the GenesisCeremonyComplete != True assertion and the abandoned-plan ID/task-index in the event message for a follow-up unless you want them here.
Coverage note: the envtest still holds the plan open at assemble-genesis (the stub sidecar has no per-task delay), but the recovery path is now identical regardless of which task was current, and the unit test asserts the survivor deletes.
1. Blocking
The guard fires on any active ceremony plan regardless of which task is current, but the ceremony is not re-runnable from the middle. Two sidecar-side idempotency markers make the rebuilt ceremony a partial no-op:
The assembler is the lexicographically-first child ( Concretely, with The mirror case is no better: if the lost node is the assembler, the fresh Today's behaviour is a wedged network that an operator can see. This trades it for a network that says it is fine and can never produce a block. The guard needs to be scoped to the window where restarting is actually sound (before Missing coverage for the window where the recovery is unsound. 2. Non-blocking
3. SummaryThe diagnosis in the PR body is right and the mechanism — abandon the plan so the seidroid review · decision Findings: 2 blocking | 3 non-blocking | 1 posted inline | 1 pre-existing |
…r is lost mid-ceremony Abandoning the plan alone left survivors on the genesis assembled from the lost node's gentx: the sidecar's assemble and configure-genesis markers make the rebuilt ceremony skip that work, so the set would split across two genesis hashes (or latch complete over a validator key nobody holds). Deleting every remaining founding child recreates the set with fresh PVCs and identities, and the rebuilt ceremony derives genesis from the new set. Terminating children are no longer counted as incumbents, so the loss is detected as soon as the delete lands and no ceremony is built over a node still held by its finalizer. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
|
||
| // Drive active plan. | ||
| if network.Status.Plan != nil && network.Status.Plan.Phase == seiv1alpha1.TaskPlanActive { | ||
| if validatorLost(network) { |
There was a problem hiding this comment.
blocker — Tearing down the whole set is the right call for the case this PR targets, and it does close the stale-genesis hole: handleNodeDeletion calls deleteNodeDataPVC (internal/controller/node/controller.go:446), so the survivors lose .sei-sidecar-assemble-done / .sei-sidecar-genesis-done and every founding node re-derives from the new set.
What changed is the blast radius. validatorLost is still len(Status.IncumbentNodes) < Spec.Replicas under any active plan, and its justification — "Nothing of the chain exists yet, so restarting from an empty set is the only convergent outcome" — is asserted in a comment and never checked. It is false in a flow this repo supports:
- Retain teardown:
orphanChildSeiNodesstrips the owner ref, children keep running with their data PVCs. - The operator recreates the same-named SeiNetwork over them —
ensureSeiNode's adoption branch exists for exactly this. - The new network's status is empty, so
GenesisCeremonyCompleteseeds False/NotStarted; once the children are adopted,needsGenesisPlanbuilds a genesis ceremony plan over established, block-producing validators. - Any incumbent shortfall while that plan is active — the event class this PR exists to handle — now deletes every remaining child, and each SeiNode finalizer deletes its data PVC.
That destroys "a ceremony-generated consensus identity that cannot be regenerated" — this controller's own words, in the handleDeletion comment that biases the unknown-policy branch toward retaining precisely because the two outcomes are not symmetric. This adds a path that cascades without the operator choosing it. The window in step 4 is short (markers make assemble/configure no-ops and the children are already Running), but a short window is not a guard for an irreversible fleet-wide delete.
Before deleting the survivors, assert positively that they are children this ceremony minted and that hold no chain state — e.g. record the founding set on the plan at startPlan and tear down only nodes in that set, rather than everything listChildSeiNodes returns. Where the assertion does not hold, abandon the plan and surface ValidatorLost without deleting. Note the retain annotations cannot serve as that signal: ensureSeiNode replaces existing.Annotations wholesale with seiNodeAnnotations(network) on the adopting pass, so they are gone by the time this guard runs.
There was a problem hiding this comment.
Good catch — the Retain-then-recreate adoption flow does put a ceremony plan over established validators, and the count alone did not distinguish it. Fixed in ee10989.
The positive signal is age: a child this network minted was created after the network (ensureSeiNode runs only once the network exists), while an adopted child predates it. abandonPlanForLostValidator now requires every survivor to satisfy !node.CreationTimestamp.Before(&network.CreationTimestamp) before deleting anything. If any survivor predates the network the set is treated as adopted: the plan is abandoned and ValidatorLost surfaced (message says the adopted validators are kept), nothing is deleted, and the gate reopens so the lost node is recreated and joins the existing chain via the retained genesis. I went with the timestamp rather than recording the founding set on the plan because the adopted names are the founding names — the plan would have listed them too — and it needs no API change.
Unit coverage: TestReconcilePlan_ValidatorLostOverAdoptedSet_KeepsSurvivors (adopted survivors intact, plan cleared, condition set) alongside the existing minted-set teardown test.
Non-blockers picked up in the same commit: a distinct Warning/FoundingSetTornDown event per deleted survivor instead of the scale-down SeiNodeDeleted; the abandoned plan ID in the condition/event message; survivorsDeleted now counts actual deletes; the SetCompleteAfter comment now states the delay is read at poll time and applies retroactively; and the envtest comment states it proves recreation, not marker-clearing (that rests on the SeiNode finalizer's deleteNodeDataPVC, covered by TestNodeDeletion_SnapshotNode_WithoutRetain_DeletesPVC). Task index of the abandoned plan is not recorded in status — the log line and the plan ID are the pointer; happy to add it if you want it in the condition message.
1. Blocking
The teardown is the right call for the case the PR is written for, and it does close the stale-genesis hole from the previous revision: What changed is the blast radius.
That destroys "a ceremony-generated consensus identity that cannot be regenerated" — this controller's own words, in the The window in step 4 is short (the markers make Before deleting the survivors, the code needs a positive assertion that they are children this ceremony minted and that hold no chain state — for example, recording the founding set on the plan at 2. Non-blocking
3. SummaryThe change moved substantially: seidroid review · decision Findings: 1 blocking | 5 non-blocking | 1 posted inline | 1 pre-existing |
…-ceremony loss The whole-set teardown assumed every survivor was minted by this ceremony. A same-named SeiNetwork recreated over children a Retain teardown released adopts them and runs a marker-no-op ceremony over established validators; a loss under that plan must not cascade into deleting identities that cannot be regenerated. Survivors that predate the network are adopted: the plan is abandoned and ValidatorLost surfaced, but nothing is deleted. Also: a distinct Warning/FoundingSetTornDown event per deleted survivor, the abandoned plan ID in the condition/event message, and an accurate survivorsDeleted log count. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
| msg = fmt.Sprintf("%d of %d founding validators present; plan %s abandoned, the set is torn down and the genesis ceremony restarts once it is recreated", | ||
| len(network.Status.IncumbentNodes), network.Spec.Replicas, network.Status.Plan.ID) | ||
| } else { | ||
| msg = fmt.Sprintf("%d of %d validators present; plan %s abandoned, adopted validators are kept and the missing node is recreated", |
There was a problem hiding this comment.
blocker — The guard is the right instinct and the comment states the right intent — "the plan is abandoned, the loss is surfaced, and the lost node is recreated to join the existing chain." The code does the first two, then does more than the third, because both branches fall through to the same two lines: GenesisCeremonyComplete = False/ValidatorLost and Status.Plan = nil. That is exactly what needsGenesisPlan (internal/planner/planner.go) reads as "build the ceremony again" — it returns false only for nil-plan-plus-True, and False/ValidatorLost is not True.
On an adopted (Retain-teardown-then-recreated) set the sequence continues past the abandon:
PlanInProgressdrops, soreconcileSeiNodesrecreates the lost child — with a fresh data PVC, its predecessor's having been removed by the SeiNode finalizer.- That child's plan is
buildGenesisPlan, so it mints a new identity andupload-genesis-artifactswrites a new gentx to S3 under the same deterministic node name, replacing the dead validator's. - Once all replicas exist the ceremony is rebuilt over the mixed set. The assembler is the lexicographically-first child (
cmd/main.gosorts by name, takes[0]).- Lost node was ordinal 0: the replacement is the assembler and has no
.sei-sidecar-assemble-done, soassemble-genesisruns for real — pulls every gentx, assembles a different genesis, and uploads it back to S3 over the live chain's published genesis.json. The adopted survivors are marker-guarded onconfigure-genesisand keep the original, so the set splits across two genesis hashes and the canonical artifact misdirects any future bootstrap. - Any other ordinal: the assembler is an adopted survivor, assemble no-ops, and the replacement installs the existing genesis, which does not contain its key — the chain permanently loses that slot's voting power.
- Lost node was ordinal 0: the replacement is the assembler and has no
await-nodes-runningonly checks PhaseRunning, so the plan completes and GenesisCeremonyComplete latches True: the network reports the ceremony succeeded either way.
None of this was reachable before — the loss under an active plan wedged the plan, so no ceremony was ever rebuilt over an established set containing a marker-less member. Abandoning the plan is what opens it.
In the adopted case the ceremony must not be rebuilt at all: make needsGenesisPlan false (latch GenesisCeremonyComplete=True under a distinct reason, or gate the rebuild explicitly) so only the plain replacement path runs — what this function's own comment describes. Whether a replacement can meaningfully rejoin a minted validator set is a separate question the PR scopes out, and that is fine; re-running assembly over a live chain is not.
There was a problem hiding this comment.
Right — the adopted branch abandoned the plan but left the rebuild open. Fixed in 0deac0f: the adopted branch now latches GenesisCeremonyComplete=True/AdoptedSet (new stable reason) instead of False/ValidatorLost, so needsGenesisPlan stays false and only the plain replacement path runs; PlanInProgress still drops with ValidatorLost. setGenesisCeremonyCondition's True-latch leaves it alone (asserted in TestReconcilePlan_ValidatorLostOverAdoptedSet_KeepsSurvivors, which now also checks Status=True/AdoptedSet).
Non-blockers: both unit tests now use real, well-separated timestamps (network an hour old, minted children a minute after it) so they no longer pin "equal ⇒ tear down"; the envtest comment cites TestNodeDeletion_SnapshotNode_WithoutRetain_DeletesPVC by name — it asserts the PVC is gone after SeiNode delete, which is deleteNodeDataPVC's effect even if the function isn't referenced literally; and the condition message now reports the actual number of survivors deleted, so a zero-survivor loss reads as such. I kept the timestamp proxy over recording the founding set on the plan: the adopted names are the founding names, so a name list would not separate the two cases, and it fails safe (any single older survivor selects the non-destructive branch).
1. Blocking
The guard itself is the right instinct and the comment states the right intent: "the plan is abandoned, the loss is surfaced, and the lost node is recreated to join the existing chain." The code does the first two and then does considerably more than the third, because both branches land on the same two lines — So on an adopted (Retain-teardown-then-recreated) set the sequence continues past the abandon:
None of this was reachable before this change — the loss under an active plan wedged the plan, so no ceremony was ever rebuilt over an established set containing a marker-less member. Abandoning the plan is what opens it. For the adopted case the ceremony must not be rebuilt at all: make 2. Non-blocking
3. SummaryThe revision adds an adoption guard: seidroid review · decision Findings: 1 blocking | 3 non-blocking | 1 posted inline | 1 pre-existing |
…d of rebuilding over an adopted set Abandoning the plan with GenesisCeremonyComplete=False let needsGenesisPlan rebuild the ceremony over adopted validators plus a marker-less replacement, which could reassemble and republish genesis over the live chain's. The adopted branch now latches the condition True under AdoptedSet so only the plain replacement path runs. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 0deac0f. Configure here.
Superseded: the latest review found nothing blocking in this change.
1. BlockingNothing. 2. Non-blocking
3. SummaryThe adopted branch now latches seidroid review · decision Findings: 0 blocking | 2 non-blocking | 1 posted inline | 2 pre-existing |
… degraded adopted-set outcome Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

Summary
Closes PLT-1242 (found while deflaking PLT-1241 / #543). A founding validator SeiNode deleted while the genesis ceremony plan is active wedges the SeiNetwork forever:
reconcileSeiNodesdefers creates whilePlanInProgress=True(deliberate — the ceremony stamps peers on the children);collect-and-set-peers/await-nodes-runningGetthe founding set by the names frozen into the plan and error on the missing one, so the plan just retries;needsGenesisPlanreturns false whileStatus.Plan != nil, so nothing ever rebuilds it.Fix, in
reconcilePlanbefore driving an active plan:abandonPlanForLostValidatordeletes every surviving founding child this network minted, clearsStatus.Plan, setsPlanInProgress=False/ValidatorLostandGenesisCeremonyComplete=False/ValidatorLost, and emits a Warning event. The next reconciles recreate the whole set through the normalensureSeiNodepath andneedsGenesisPlanrebuilds the ceremony once every replica exists again.Why the whole set and not just the lost node: the sidecar gates
assemble-genesisandconfigure-genesison marker files in the data PVC. A survivor that kept its PVC would keep the genesis minted from the dead node's gentx (and the assembler would skip reassembly), so the rebuilt ceremony would either split the set across two genesis hashes or latchGenesisCeremonyComplete=Trueover a validator set containing a key nobody holds. Deleting the survivors makes the SeiNode finalizer remove their PVCs — markers included — so every founding node re-derives from the new set. A set the ceremony has not finished minting holds no chain state, so an empty restart is the only convergent outcome. Each survivor delete emitsWarning/FoundingSetTornDown(distinct from the scale-downSeiNodeDeleted).ValidatorLostis sticky insetGenesisCeremonyConditionlikeCeremonyFailedbut is intentionally not mapped toGroupPhaseFailed: it self-heals.Adopted sets are never torn down. A Retain teardown orphans children with their consensus identities and chain data; a same-named SeiNetwork recreated over them adopts them and runs a (marker-no-op) ceremony over established validators. A loss under that plan must not cascade. The positive signal is age: minted children are created after the network, adopted children predate it. If any survivor has
CreationTimestamp.Before(network.CreationTimestamp)the plan is abandoned, nothing is deleted, andGenesisCeremonyCompletelatchesTrue/AdoptedSetsoneedsGenesisPlannever rebuilds a ceremony over the adopted set plus a marker-less replacement (which could reassemble and republish genesis over the live chain's). The gate reopens and the lost node is recreated through the plain replacement path.populateIncumbentNodesnow skips children with aDeletionTimestamp. A child held in Terminating by its finalizer is not a ceremony participant: counting it let the planner rebuild the ceremony over a node on its way out, which the network then had to abandon a second time.The check is count-based rather than name-based because replicas are immutable once the ceremony starts and creates are gated under the plan, so "fewer incumbents than replicas under an active plan" can only mean a founding child vanished.
Tests
TestReconcilePlan_ValidatorLostMidCeremony_AbandonsPlan(unit): survivors deleted, plan cleared, both conditionsValidatorLost, immediate requeue, seed keeps the reason.TestReconcilePlan_ValidatorLostOverAdoptedSet_KeepsSurvivors(unit): survivors older than the network are kept, plan abandoned,GenesisCeremonyComplete=True/AdoptedSetlatched.TestPopulateIncumbentNodes_ExcludesTerminatingChildren(unit).TestGenesisCeremony_ValidatorLostMidCeremony_Recovers(envtest): deletes-1under an active plan and waits for theValidatorLostevent, both children recreated with new UIDs, andGenesisCeremonyComplete=Trueover the new set. Two test-harness enablers:StubSidecarClient.SetCompleteAfter(locked setter; the instant stub otherwise finishes the ceremony within one reconcile lap so there is no window to delete under) and atestStubhandle in the suite. The test strips finalizers and deletes both data PVCs up front, standing in for the GC envtest doesn't run — otherwise a recreated child's init plan refuses to adopt the orphaned claim. It therefore proves recreation + rebuilt ceremony, not marker clearing; that rests on the SeiNode finalizer'sdeleteNodeDataPVC(TestNodeDeletion_SnapshotNode_WithoutRetain_DeletesPVC).Verified locally: seinetwork + planner unit tests, full
internal/controller/seinetwork/envtestpackage ×3 consecutive,golangci-linton the package (0 issues).Not in scope: the SeiNode-level behavior when a replacement lands on a retained PVC (
data PVC ... already exists and is not owned by SeiNode) — that is Spec 004 territory.Link to Devin session: https://app.devin.ai/sessions/b2e0897e975b458598ae62a68176b7a3
Open in Devin Desktop: https://app.devin.ai/desktop/session/b2e0897e975b458598ae62a68176b7a3?variant=devin
Requested by: @bdchatham