Skip to content

feat(seinode): declarative data reset with a start guard (spec 009, PLT-1342) - #594

Merged
bdchatham merged 6 commits into
mainfrom
brandon2/plt-1342-data-reset
Oct 7, 2026
Merged

bdchatham merged 6 commits into
mainfrom
brandon2/plt-1342-data-reset

Conversation

@bdchatham

@bdchatham bdchatham commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This PR implements spec 009 (#590). A nodeConfig SeiNode can now reset its data with one GitOps commit, and nothing can start seid while a reset is pending.

  • API. spec.dataResetGeneration is the request. status.dataResetGeneration is the last value the controller handled. CEL rejects a lowered or removed counter, and a counter on a node without nodeConfig. While the node is not Running, the handled counter follows the spec, so creating a node never wipes it.

  • Reset plan. It comes before any other Running-phase plan:

    1. observe-image, which waits for the rollout
    2. mark-not-ready
    3. stop-seid
    4. reset-data, with 5 retries and backoff
    5. record-data-reset
    6. mark-ready

    The same sequence runs whether or not the pod rolled. The controller does not treat a configRef change as drift (planner.go:852-854).

  • Start guard (internal/task/start_guard.go). The mark-ready deserializer checks the target SeiNode at submission. The plan executor and the MarkReady SeiNodeTask both reach that deserializer, with the SeiNode as cfg.Resource. While a reset is pending, mark-ready fails as terminal and submits nothing. A plan built before the counter bump fails, and the planner builds the reset plan next.

  • Sign state (sidecar/tasks/reset_data.go).

    • The wipe skips priv_validator_state.json, so a crash during the wipe cannot leave a rerun that writes a zero state.
    • The zero state is written only when the file is absent, and it is fsynced.
    • The reset refuses while a seid or seidb process runs in the pod, found through /proc/*/comm.
    • The reset refuses when config.toml sets a non-default [priv-validator] state-file, or cannot be parsed.
  • Status.

    • DataResetInProgress is always present. Its reasons are NotApplicable, NoResetRequested, ResetPending, ResetRunning, ResetComplete, and ResetFailed.
    • ResetFailed stays through retries until a reset succeeds.
    • Each transition records an event: DataResetStarted, DataResetComplete, or DataResetFailed.

Behaviour change

reset-data used to replace the sign state with a zero state. Now it keeps an existing file. This also applies to the SeiNodeTaskWorkflow StateSync recipe, which only targets non-validators, where the file is unused.

Needs sign-off

The new API fields are listed in #590. Please approve their shape before this PR merges.

Tests (each names its spec requirement)

Spec item Test
Req 1 / SC-001 internal/controller/node/envtest/dataresetgeneration_validation_test.go
Req 1.5 / SC-002 TestDataReset_NotRunningAbsorbsCounter
Req 2 / SC-003 TestDataReset_PlanOrder, TestDataReset_PrecedesUpdateAndReapproval
Req 3.1–3.2 / SC-004 TestDataReset_StaleUpdatePlanCannotReleaseGate, TestMarkReady_StartGuard
Req 3.3 / SC-005 TestReconcile_MarkReady_RefusedWhileResetPending
Req 2.4 / SC-006 TestDataReset_NoPlanWhenHandled
Req 4 / SC-007 TestResetData_KeepsSignStateAndKeys, TestResetData_RerunAfterPartialWipeKeepsSignState, TestResetData_StateFilePath, TestResetData_RefusesWhileDataUserRuns
Req 5 / SC-008 TestDataReset_ConditionTransitions, TestDataReset_FailureStaysReadableUntilSuccess
Counter rises during a reset TestDataReset_CounterRisesDuringReset

SC-009 (harbor state sync) and SC-010 (runbook) are judgement checks. They are not run yet. The runbook is sei-protocol/runbooks#162.

Independent review: fixes in 4c8dc77

Finding Severity Fix
The sign-state guard read only [priv-validator] state-file. sei-config also writes [priv_validator] state_file. High reset-data checks both spellings, and refuses a value it cannot read (TestResetData_StateFileSpellingsAndShapes).
An older sidecar would run the old reset-data and zero the sign state, if the controller shipped first. High (found during the fix) New wire type reset-data-keep-sign-state, which an older sidecar rejects.
A guard refusal of the final mark-ready read as ResetFailed, although the wipe ran. Medium It now reads ResetComplete with "start deferred" (TestDataReset_DeferredStartIsNotAFailure).
The sidecar can rehydrate a stranded mark-ready after a crash. Medium Documented, not fixed. The window is one no-op handler, and the sign state still guards against a double sign (spec 009 Assumptions).
A status flush conflict could cost one extra wipe. Low Checked and not real. Task IDs are deterministic, so the rerun polls the same completed reset-data.

Deploy and rollback order

  • Deploy the sidecar image first, then the controller. The reset plan submits reset-data-keep-sign-state, a wire type that only this sidecar accepts. If the controller ships first, an older sidecar rejects that type. The reset then retries with seid held, instead of running the old reset-data, which zeroes the sign state.
  • Roll back the controller only after every reset plan finishes. An older controller does not know record-data-reset or reset-data-keep-sign-state, so a persisted reset plan fails with UnknownTaskTypeError.

Verification

All commands ran with env -u GOROOT GOTOOLCHAIN=go1.26.0, because gvm on this machine pins GOROOT to 1.25.6.

Check Result
gofmt -l (3 modules) no output
go vet ./... (3 modules) exit 0
golangci-lint v2.12.1 --new-from-merge-base=origin/main (3 modules) 0 issues. each
make test exit 0; 29 packages ok
envtest (4 suites, k8s 1.34) all ok
make tidy-check, make verify-generated, make build pass
staticcheck ./... findings only in files this PR does not touch (deprecations, one unused test helper)
govulncheck ./... 28 / 16 / 28 findings in the Go 1.26.0 stdlib and pinned deps. These are the same counts as other branches. go.mod is unchanged.

Refs: PLT-1342, PLT-1389

🤖 Generated with Claude Code

bdchatham and others added 2 commits October 6, 2026 12:10
spec.dataResetGeneration requests one data reset per increase on a
nodeConfig SeiNode; status.dataResetGeneration records the last value
handled. CEL keeps the counter monotonic and nodeConfig-only.

The planner builds the reset plan before any other Running-phase plan:
observe-image, mark-not-ready, stop-seid, reset-data, record-data-reset,
mark-ready. One sequence serves every case, because a configRef change
is not observed as drift.

mark-ready is now start-guarded: while a reset is pending, a plan's
mark-ready and a MarkReady SeiNodeTask fail terminally and submit
nothing. A plan built before the counter bump can no longer release
seid onto old data; the planner rebuilds from the current spec.

reset-data keeps priv_validator_state.json in place instead of deleting
and rewriting it, so a crash mid-wipe cannot zero a validator's sign
state. It refuses while a seid or seidb process runs in the pod, and
when config.toml moves [priv-validator] state-file.

DataResetInProgress reports ResetPending, ResetRunning, ResetComplete,
and ResetFailed (sticky until a retry succeeds), with events.

Refs: PLT-1342, spec PR #590

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs: PLT-1342

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- reset-data reads both sign-state spellings, CometBFT's
  [priv-validator] state-file and sei-config's [priv_validator]
  state_file, and refuses on a value it cannot read. The guard read
  only the first spelling, so the second could move the sign state
  under the wipe.
- The reset plan submits reset-data-keep-sign-state, a wire type only a
  sidecar that keeps the sign state accepts. Against an older sidecar
  the submission fails and retries with seid held, instead of zeroing
  a validator's sign state when the controller deploys first.
- A reset plan whose final mark-ready the start guard refused (the
  counter rose again, or a hold arrived) reads ResetComplete with
  "start deferred", not ResetFailed: the wipe ran and was recorded.

Refs: PLT-1342

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bdchatham

Copy link
Copy Markdown
Collaborator Author

@seidroid review

@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.

Adds spec.dataResetGeneration, which lets a nodeConfig SeiNode wipe its data/ directory through a dedicated reset plan. A start guard on every mark-ready path keeps seid held while a reset is pending, and a new sidecar wire type keeps priv_validator_state.json through the wipe. I approve because the guard, the counter handling, the plan ordering and the sign-state keep hold up against the tree and nothing blocks; two suggestions and one nit remain. Of codex's two findings I kept neither. sei-tendermint maps the sign state only from [priv-validator] state-file, so seid never reads a root-level priv_validator_state_file. tomlpatch.ReadTOML returns an empty map when the file is missing, so TestResetData_MissingDataDirIsSuccess still passes.

Non-blocking

1 finding on the changed lines, as inline comments.

  • While a reset is pending, a MarkReady SeiNodeTask now fails terminally, which changes how that kind behaves. CLAUDE.md requires updating the seinode-task design doc in bdchatham-designs for such a change, and the gov-ops skill cites its headings. Nothing in this PR shows that the doc's MarkReady section records the new refusal.
1 nit, not posted on the code
  • internal/controller/node/controller.go:336 — This compares only the reconcile-start condition with the end state. On a deferred start, handleTerminalPlan sets ResetComplete and markDataResetStarted overwrites it with ResetRunning in the same reconcile. DataResetComplete is therefore never recorded for the reset that did run, although the PR says every transition records an event.

seidroid review · decision approve · session e5b46a97d73e48efa9f57026ab6ebb6b · turn resp_claude_82e1b3eb203b5e9703a9638e59258940 · item 8fc27e85400e56319c3d4a4e5dc3d814

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

Comment thread internal/planner/planner.go
Addresses seidroid's suggestion and nit on #594.

- An update plan whose final mark-ready the start guard refused now
  sets NodeUpdateInProgress=False/UpdateComplete with "start deferred".
  The roll landed; UpdateFailed would have stayed until the next update.
- A reset plan deferred because the counter rose again reads
  True/ResetPending for the newer value, not ResetComplete.
- A ResetRunning to ResetRunning move for a newer counter records a
  DataResetStarted event.

Refs: PLT-1342

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bdchatham added a commit that referenced this pull request Oct 6, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bdchatham

Copy link
Copy Markdown
Collaborator Author

@seidroid review

@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.

Since the last review, a stale update plan that the start guard defers now ends with NodeUpdateInProgress=False/UpdateComplete instead of UpdateFailed. A counter that rises during a reset now reads ResetPending, and the move from one ResetRunning to the next now records DataResetStarted; both are tested, so the open thread and the events nit are addressed. Codex repeated its two findings, and I dropped both again. seid's sei-tendermint config reads the sign-state path only from [priv-validator] state-file, never from a root-level priv_validator_state_file. tomlpatch.ReadTOML returns an empty map when config.toml is missing, so TestResetData_MissingDataDirIsSuccess still passes. That leaves one earlier non-blocker and nothing blocking.

Non-blocking

  • While a reset is pending, a MarkReady SeiNodeTask now fails terminally, which changes how that kind behaves. CLAUDE.md requires updating the seinode-task design doc in bdchatham-designs for such a change, and the gov-ops skill cites its headings. Nothing in this PR shows that the doc's MarkReady section records the new refusal.

seidroid review · decision approve · session e5b46a97d73e48efa9f57026ab6ebb6b · turn resp_claude_0ae012d2b2c1ad5a520f9ed315d5ad8f · item a602b5c6725955eeaddba2db8b32c098

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

@bdchatham

Copy link
Copy Markdown
Collaborator Author

On the design-doc note: sei-protocol/bdchatham-designs#140 records the new MarkReady refusal in the kinds table and as gotcha 4. It covers both a pending reset (this PR) and a maintenance hold (#595). No heading changes.

bdchatham and others added 2 commits October 6, 2026 15:55
…t branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bdchatham
bdchatham marked this pull request as ready for review October 7, 2026 00:16
@bdchatham
bdchatham merged commit c03673d into main Oct 7, 2026
12 checks passed
@cursor

cursor Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

PR Summary

High Risk
Touches chain data deletion, validator double-sign protection, and the seid start gate—incorrect ordering or sign-state handling could cause data loss or unsafe validator restarts.

Overview
Adds a GitOps-driven data reset for nodeConfig SeiNodes: bump spec.dataResetGeneration (monotonic, CEL-gated) to request a wipe of <home>/data/ while seid stays at the sidecar start gate; completion is status.dataResetGeneration >= N, not a condition alone.

The controller runs a reset plan before any other Running-phase work (observe-image → hold/stop → reset-data-keep-sign-state → record counter → mark-ready), maintains DataResetInProgress status and Kubernetes events, and wraps mark-ready with a start guard so stale update plans and MarkReady tasks cannot start seid on uncleared data.

Sidecar behavior changes: reset-data now preserves priv_validator_state.json (new wire type for version safety), refuses unsafe conditions (live RPC, seid/seidb in the pod, non-default sign-state path in config.toml), and only writes a zero sign state when the file is absent.

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

bdchatham added a commit that referenced this pull request Oct 7, 2026
main's tip (c03673d, the #594 squash) has the same tree as 865c4e8, which
this branch already contains, so the merge keeps this branch's tree.
bdchatham added a commit that referenced this pull request Oct 7, 2026
…390) (#595)

* feat(seinode): declarative data reset with a start guard (spec 009)

spec.dataResetGeneration requests one data reset per increase on a
nodeConfig SeiNode; status.dataResetGeneration records the last value
handled. CEL keeps the counter monotonic and nodeConfig-only.

The planner builds the reset plan before any other Running-phase plan:
observe-image, mark-not-ready, stop-seid, reset-data, record-data-reset,
mark-ready. One sequence serves every case, because a configRef change
is not observed as drift.

mark-ready is now start-guarded: while a reset is pending, a plan's
mark-ready and a MarkReady SeiNodeTask fail terminally and submit
nothing. A plan built before the counter bump can no longer release
seid onto old data; the planner rebuilds from the current spec.

reset-data keeps priv_validator_state.json in place instead of deleting
and rewriting it, so a crash mid-wipe cannot zero a validator's sign
state. It refuses while a seid or seidb process runs in the pod, and
when config.toml moves [priv-validator] state-file.

DataResetInProgress reports ResetPending, ResetRunning, ResetComplete,
and ResetFailed (sticky until a retry succeeds), with events.

Refs: PLT-1342, spec PR #590

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(sidecarapi): ResetDataTask keeps the sign state

Refs: PLT-1342

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(seinode): maintenance hold for nodeConfig nodes (spec 010)

spec.maintenance.hold parks seid at the sidecar start gate with the pod
alive, for seid rollback and chain-halt work through kubectl exec.
status.maintenanceHold records the hold in effect; every plan that
changes it ends with record-maintenance-hold.

- Immediate: mark-not-ready, stop-seid.
- AfterExit on a running seid: mark-not-ready only, so seid parks when
  it exits, for example at halt-height.
- AfterExit on a parked seid: start once (start-seid-once, the new
  read-only sidecar task await-seid-start, mark-not-ready), so one
  commit can stage a recovery binary and halt-height and run to it.
- Release: mark-ready.

The start guard now also refuses mark-ready while a hold is set; the
hold's own start-once step obeys only the reset half. While held, the
reset plan and the init plan park instead of releasing, the update plan
carries no mark-ready, and no readiness reapproval is built. The
StatefulSet still applies, so a template change rolls the pod parked.

MaintenanceInProgress reports NotHeld, HoldPending, Held, Armed, and
NotApplicable, with MaintenanceHeld/Armed/Released events.

Refs: PLT-1390, spec PR #590

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(seinode): close review findings on the data reset

- reset-data reads both sign-state spellings, CometBFT's
  [priv-validator] state-file and sei-config's [priv_validator]
  state_file, and refuses on a value it cannot read. The guard read
  only the first spelling, so the second could move the sign state
  under the wipe.
- The reset plan submits reset-data-keep-sign-state, a wire type only a
  sidecar that keeps the sign state accepts. Against an older sidecar
  the submission fails and retries with seid held, instead of zeroing
  a validator's sign state when the controller deploys first.
- A reset plan whose final mark-ready the start guard refused (the
  counter rose again, or a hold arrived) reads ResetComplete with
  "start deferred", not ResetFailed: the wipe ran and was recorded.

Refs: PLT-1342

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(seinode): close review findings on the maintenance hold

- The hold half of the start guard acts only on a Running node. A hold
  set while an init plan ran made the guard refuse that plan's
  mark-ready, and an init plan failure is terminal. Now seid starts
  once and the hold plan stops it when the node reaches Running.
- await-seid-start fails after two minutes. Unbounded, a pod roll
  between start-seid-once and seid starting held the node's only plan
  slot forever; now the plan fails and is built again.

Refs: PLT-1390

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(seinode): hold never reports Held while seid can start

Addresses seidroid's two blocking findings on #595.

- start-seid-once now refuses unless the requested hold is still
  AfterExit. A start-once plan already running when the operator changed
  the hold back to Immediate no longer starts seid.
- A hold in effect is level-triggered against the observed start gate:
  when the sidecar reports ready (gate open) under a hold, the planner
  rebuilds the hold plan. A failed start-once plan that left the gate
  open is closed again even though want == have.
- MaintenanceInProgress reads HoldPending, never Held or Armed, while a
  hold plan runs or while the gate is open under a hold.
- A held init plan keeps its "init" metric label (nit).

Refs: PLT-1390

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(seinode): a deferred start is not a failed update or reset

Addresses seidroid's suggestion and nit on #594.

- An update plan whose final mark-ready the start guard refused now
  sets NodeUpdateInProgress=False/UpdateComplete with "start deferred".
  The roll landed; UpdateFailed would have stayed until the next update.
- A reset plan deferred because the counter rose again reads
  True/ResetPending for the newer value, not ResetComplete.
- A ResetRunning to ResetRunning move for a newer counter records a
  DataResetStarted event.

Refs: PLT-1342

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(seinode): a hold arriving mid-reset completes the reset

Refs: PLT-1390

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(seinode): close an open gate before retrying start-once

Addresses seidroid's follow-up suggestion and nit on #595.

Open-gate recovery now covers any requested hold, not only want == have.
When start-once fails after opening the gate, want stays AfterExit and
the hold in effect stays Immediate; the planner now applies Immediate
again first (mark-not-ready, stop-seid), so a crash-looping seid cannot
keep the gate open across retries. The next plan retries start-once with
the gate closed. The registry comment for start-seid-once now names both
of its guards.

Refs: PLT-1390

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(seinode): record MaintenanceReleased when a release passes through HoldPending

Found by the harbor e2e: the release plan runs before NotHeld, so the
condition moves HoldPending -> NotHeld and the old Held/Armed-only check
never recorded the event spec 010 Req 5.6 requires. Any True state before
NotHeld now counts as a release.

Refs: PLT-1390

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(planner): clear the hold in effect when a reset releases it

A hold removed in the same commit that raises dataResetGeneration ran the
plain reset plan. That plan ends in mark-ready but never recorded the
release, so status.maintenanceHold stayed set and MaintenanceInProgress
read Held while the wipe ran and seid started.

- The reset plan for a node whose hold is in effect but no longer requested
  ends mark-ready -> record-maintenance-hold(""). The plan now counts as a
  hold plan, so the condition reads HoldPending while it runs.
- startDeferred reads only the tasks before mark-ready, so the trailing
  record step keeps a refused start a deferred start, not ResetFailed.
- ResolveMaintenance reports a pending release as HoldPending, as the
  ReasonHoldPending API comment already states.
- Spec 010 Req 4.2, 5.2, and 5.3 now say so.

Refs: PLT-1390

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant