provision, hostbox, dbverify: the landing's rollback marker and phase order, the shared .gen marker writer, RemoveHost inside the CAS
provision.MoveMarkRollback records on a landing record why the landing stopped (the deadline, a configuration change, host claims on, a nested row, an install mismatch), as five recorded sentences (MV-N168 to MV-N172, pending), leaving state and phase alone: Plan 4's rollback transition replaces the one call site. LandingOrder is the phase order the claim's op.phase is walked along (section 7.5). hostbox.WriteGenerationMarker and ReadGenerationMarker export the cold restore's plain-text generation marker, the one <dbPath>.gen format (ruling G2). dbverify.RemoveHost drops a moved host's verdict with the load, delete and put inside each CAS attempt (ruling G31).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
8 files changed,
+262
−2
approved_moves_copy_test.go+4 −1docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+9 −1internal/dbverify/dbverify.go+36 −0internal/dbverify/dbverify_test.go+63 −0internal/hostbox/restore.go+19 −0internal/hostbox/restore_test.go+27 −0internal/provision/move_test.go+40 −0internal/provision/provision.go+64 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index a4d21b43a..d76463e37 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -66,7 +66,8 @@ var movesCopyIDs = []string{ |
| "MV-N145", "MV-N146", "MV-N147", "MV-N148", "MV-N149", "MV-N150", |
| "MV-N151", "MV-N152", "MV-N153", "MV-N154", "MV-N155", "MV-N156", |
| "MV-N157", "MV-N158", "MV-N159", "MV-N160", "MV-N161", "MV-N162", |
| - "MV-N163", "MV-N164", "MV-N165", "MV-N166", "MV-N167", |
| + "MV-N163", "MV-N164", "MV-N165", "MV-N166", "MV-N167", "MV-N168", |
| + "MV-N169", "MV-N170", "MV-N171", "MV-N172", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
| @@ -540,6 +541,7 @@ var movesScanFiles = []string{ |
| "internal/console/boxselect.go", |
| "internal/console/instancesplacement.go", |
| "internal/console/server.go", |
| + "internal/dbverify/dbverify.go", |
| "internal/edge/door.go", |
| "internal/edge/edge.go", |
| "internal/genselect/select.go", |
| @@ -566,6 +568,7 @@ var movesScanFiles = []string{ |
| "internal/host/movesstore.go", |
| "internal/host/refusalwatch.go", |
| "internal/host/systemapply.go", |
| + "internal/hostbox/restore.go", |
| "internal/hostbox/writer.go", |
| "internal/hostclaim/move.go", |
| "internal/lease/endpoint.go", |
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md| index efbeeaec5..6b48b11e2 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -210,7 +210,15 @@ Columns: id, file, text, status. |
| ## Plan 2 |
| -(appended by Plan 2 Task 11) |
| +(Task 10 rows; the rest appended by Plan 2 Task 11) |
| + |
| +| id | file | text | status | |
| +|---|---|---|---| |
| +| MV-N168 | internal/provision/provision.go | configuration changed during the move | pending | |
| +| MV-N169 | internal/provision/provision.go | the landing ran past its deadline | pending | |
| +| MV-N170 | internal/provision/provision.go | this box cannot land a move while host claims are on | pending | |
| +| MV-N171 | internal/provision/provision.go | the landing row is not at the flat path | pending | |
| +| MV-N172 | internal/provision/provision.go | the installed database does not match the build | pending | |
| ## Plan 3 |
diff --git a/internal/dbverify/dbverify.go b/internal/dbverify/dbverify.go| index 10247c0ae..154b2190e 100644 |
| --- a/internal/dbverify/dbverify.go |
| +++ b/internal/dbverify/dbverify.go |
| @@ -126,6 +126,42 @@ func Load(ctx context.Context, s release.Store) (*Record, error) { |
| return &rec, nil |
| } |
| +// RemoveHost drops host's verdict from the record: a moved instance's |
| +// replica is no longer this fleet's to verify where it was (moves spec |
| +// §6.3, §7.6 local). The load, the delete and the put run inside each CAS |
| +// attempt (moves Plan 2 ruling G31), so a record another writer changed |
| +// between the read and the write is re-read, never overwritten with a stale |
| +// body. No record, or no entry for the host, is nothing to do. |
| +func RemoveHost(ctx context.Context, s release.Store, host string) error { |
| + for attempt := 0; attempt < 3; attempt++ { |
| + body, etag, err := s.Get(ctx, Key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil |
| + } |
| + if err != nil { |
| + return fmt.Errorf("get %s: %w", Key, err) |
| + } |
| + var rec Record |
| + if err := json.Unmarshal(body, &rec); err != nil { |
| + return fmt.Errorf("parse %s: %w", Key, err) |
| + } |
| + if _, ok := rec.Hosts[host]; !ok { |
| + return nil |
| + } |
| + delete(rec.Hosts, host) |
| + next, err := json.Marshal(&rec) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := s.Put(ctx, Key, next, etag); err == nil { |
| + return nil |
| + } else if !errors.Is(err, release.ErrCASConflict) { |
| + return fmt.Errorf("put %s: %w", Key, err) |
| + } |
| + } |
| + return fmt.Errorf("put %s: lost three CAS races", Key) |
| +} |
| + |
| // Publish writes rec, replacing whatever is there. Three attempts on a lost |
| // CAS race, the recipe usage.UpsertEdgeDay and host.putReplacing both use. |
| func Publish(ctx context.Context, s release.Store, rec *Record) error { |
diff --git a/internal/dbverify/dbverify_test.go b/internal/dbverify/dbverify_test.go| index 9c6dd544d..c9cb5a8b2 100644 |
| --- a/internal/dbverify/dbverify_test.go |
| +++ b/internal/dbverify/dbverify_test.go |
| @@ -122,3 +122,66 @@ func TestLoadPublishRoundTrip(t *testing.T) { |
| t.Fatalf("replace did not land: %+v", got) |
| } |
| } |
| + |
| +// A moved host's restore-verification entry is removed by the landing |
| +// (moves spec §6.3, §7.6 local): the load, the delete and the put happen |
| +// inside each CAS attempt (Plan 2 ruling G31), so a record that changed |
| +// between the read and the write is re-read rather than overwritten with |
| +// a stale body. No record, or no entry for the host, is nothing to do. |
| +func TestRemoveHostDeletesInsideTheCAS(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if err := RemoveHost(ctx, s, "a.example"); err != nil { |
| + t.Fatalf("no record: %v", err) |
| + } |
| + rec := &Record{V: 1, Box: "b", CheckedAt: at("2026-10-07T09:00:00Z"), Hosts: map[string]Verdict{ |
| + "a.example": {OK: true, VerifiedAt: at("2026-10-07T08:00:00Z")}, |
| + "b.example": {OK: true, VerifiedAt: at("2026-10-07T08:00:00Z")}, |
| + }} |
| + if err := Publish(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := RemoveHost(ctx, s, "c.example"); err != nil { |
| + t.Fatalf("no entry: %v", err) |
| + } |
| + // Another writer adds a host between this remove's read and its put: the |
| + // race is lost once, then won on a fresh read that keeps the newcomer. |
| + raced := false |
| + racing := releaseStoreFunc{Store: s, beforePut: func() { |
| + if raced { |
| + return |
| + } |
| + raced = true |
| + cur, _ := Load(ctx, s) |
| + cur.Hosts["d.example"] = Verdict{Unverified: true, VerifiedAt: at("2026-10-07T09:30:00Z")} |
| + if err := Publish(ctx, s, cur); err != nil { |
| + t.Fatal(err) |
| + } |
| + }} |
| + if err := RemoveHost(ctx, racing, "a.example"); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := Load(ctx, s) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ok := got.Hosts["a.example"]; ok || len(got.Hosts) != 2 || !got.Hosts["d.example"].Unverified || !got.Hosts["b.example"].OK { |
| + t.Fatalf("after the remove: %+v", got.Hosts) |
| + } |
| + if !raced { |
| + t.Fatal("the race was never run") |
| + } |
| +} |
| + |
| +// releaseStoreFunc is a release.Store whose Put first runs beforePut. |
| +type releaseStoreFunc struct { |
| + release.Store |
| + beforePut func() |
| +} |
| + |
| +func (r releaseStoreFunc) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if r.beforePut != nil { |
| + r.beforePut() |
| + } |
| + return r.Store.Put(ctx, key, body, ifMatch) |
| +} |
diff --git a/internal/hostbox/restore.go b/internal/hostbox/restore.go| index c4a606bd4..903e0ee4a 100644 |
| --- a/internal/hostbox/restore.go |
| +++ b/internal/hostbox/restore.go |
| @@ -204,6 +204,25 @@ func purgeDB(path string) error { |
| return nil |
| } |
| +// WriteGenerationMarker writes the <dbPath>.gen marker beside a database: |
| +// the prepared generation's name as plain text, the one format the cold |
| +// restore's readGeneration accepts. It is the shared writer the moves |
| +// landing uses for the database it installs (moves Plan 2 ruling G2; the |
| +// marker the moves spec §5.1 draws as JSON is written in this form instead, |
| +// and the incarnation and location it would have carried are vouched for by |
| +// the host guard record and the claim). A name readGeneration would refuse |
| +// is refused here, with nothing written. |
| +func WriteGenerationMarker(path, generation string) error { |
| + if !safeGeneration(generation) { |
| + return fmt.Errorf("hostbox: %q is not a generation marker: %w", generation, ErrUnsupported) |
| + } |
| + return writeGeneration(path, generation) |
| +} |
| + |
| +// ReadGenerationMarker reads a marker WriteGenerationMarker wrote. An |
| +// absent marker is "" with no error (the origin). |
| +func ReadGenerationMarker(path string) (string, error) { return readGeneration(path) } |
| + |
| func readGeneration(path string) (string, error) { |
| b, err := os.ReadFile(path) |
| if errors.Is(err, os.ErrNotExist) { |
diff --git a/internal/hostbox/restore_test.go b/internal/hostbox/restore_test.go| index 0648fcd86..14e664f3e 100644 |
| --- a/internal/hostbox/restore_test.go |
| +++ b/internal/hostbox/restore_test.go |
| @@ -312,3 +312,30 @@ func mustRead(t *testing.T, path string) []byte { |
| } |
| return b |
| } |
| + |
| +// The landing writes <dbPath>.gen through the one marker writer this |
| +// package's cold restore reads (moves Plan 2 ruling G2): the prepared |
| +// generation's name as plain text, so a later admission's readGeneration |
| +// accepts it. A name the restore would refuse is refused on the way in. |
| +func TestGenerationMarkerIsSharedWithTheColdRestore(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "h.db.gen") |
| + if err := WriteGenerationMarker(path, "mv_abc"); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, read := range []func(string) (string, error){ReadGenerationMarker, readGeneration} { |
| + if got, err := read(path); err != nil || got != "mv_abc" { |
| + t.Fatalf("read back %q, %v", got, err) |
| + } |
| + } |
| + if got, err := ReadGenerationMarker(filepath.Join(t.TempDir(), "absent.gen")); err != nil || got != "" { |
| + t.Fatalf("absent: %q, %v", got, err) |
| + } |
| + for _, bad := range []string{"", ".", "..", "instances/h", "a\nb"} { |
| + if err := WriteGenerationMarker(path, bad); err == nil { |
| + t.Fatalf("%q was written", bad) |
| + } |
| + } |
| + if got, _ := ReadGenerationMarker(path); got != "mv_abc" { |
| + t.Fatalf("a refused write changed the marker: %q", got) |
| + } |
| +} |
diff --git a/internal/provision/move_test.go b/internal/provision/move_test.go| index 4591248dc..20d29cc2d 100644 |
| --- a/internal/provision/move_test.go |
| +++ b/internal/provision/move_test.go |
| @@ -564,3 +564,43 @@ func TestBackToReadyRecordsARefusal(t *testing.T) { |
| t.Fatalf("the admission did not clear the reason: %v %+v", err, rec.Move) |
| } |
| } |
| + |
| +// The landing marks a rollback it cannot yet start (Plan 2 Task 10): the |
| +// reason is written on the landing record, the state and phase stay as they |
| +// are, and Plan 4's rollback transition is the one call site that replaces |
| +// it. Only a recorded sentence is accepted; a second write of the same |
| +// sentence is a no-op, a different one is refused; and nothing but a landing |
| +// record takes it. |
| +func TestMarkRollbackRecordsASentenceOnALandingRecord(t *testing.T) { |
| + g := cutTestGuard() |
| + rec := &Record{Region: "uk", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveLanding, LandingPhase: LandConfigured}} |
| + if err := MoveMarkRollback(g, "some error text")(rec); err == nil || errors.Is(err, ErrMoveConflict) || rec.Move.Reason != "" { |
| + t.Fatalf("an unrecorded reason: %v %+v", err, rec.Move) |
| + } |
| + for _, reason := range []string{RollbackConfigChanged, RollbackLandingDeadline, RollbackClaimWired, RollbackLandingLayout, RollbackInstallMismatch} { |
| + rec := &Record{Region: "uk", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveLanding, LandingPhase: LandChecking}} |
| + if err := MoveMarkRollback(g, reason)(rec); err != nil || rec.Move.State != MoveLanding || rec.Move.LandingPhase != LandChecking || rec.Move.Reason != reason { |
| + t.Fatalf("%q: %v %+v", reason, err, rec.Move) |
| + } |
| + if err := MoveMarkRollback(g, reason)(rec); err != nil || rec.Move.Reason != reason { |
| + t.Fatalf("%q again: %v %+v", reason, err, rec.Move) |
| + } |
| + if err := MoveMarkRollback(g, RollbackLandingDeadline)(rec); reason != RollbackLandingDeadline && (!errors.Is(err, ErrMoveConflict) || rec.Move.Reason != reason) { |
| + t.Fatalf("%q then another: %v %+v", reason, err, rec.Move) |
| + } |
| + } |
| + for _, state := range []string{MoveCutting, MoveReady, MoveReverting, MoveCommitted} { |
| + rec := &Record{Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: state}} |
| + if err := MoveMarkRollback(g, RollbackLandingDeadline)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Reason != "" { |
| + t.Fatalf("from %s: %v %+v", state, err, rec.Move) |
| + } |
| + } |
| + if got := LandingOrder(); len(got) != 9 || got[0] != LandChecking || got[8] != LandProbed { |
| + t.Fatalf("landing order: %v", got) |
| + } |
| + got := LandingOrder() |
| + got[0] = "x" |
| + if LandingOrder()[0] != LandChecking { |
| + t.Fatal("LandingOrder hands out the package's slice") |
| + } |
| +} |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 705633768..1c0a80557 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -1325,6 +1325,12 @@ var cutOrder = []string{CutRequested, CutAdmitted, CutStopping, CutStopped, CutS |
| var landingOrder = []string{LandChecking, LandParking, LandBuilding, LandPublished, LandLocal, LandConfigured, LandRestored, LandProbing, LandProbed} |
| +// LandingOrder is the landing's phase order (§7.6), a copy: the claim's |
| +// op.phase mirrors landing_phase one step at a time, and a worker that |
| +// finds the claim behind the record walks it forward along this order |
| +// (§7.5, the landing's syncClaimPhase). |
| +func LandingOrder() []string { return append([]string(nil), landingOrder...) } |
| + |
| func nextIn(order []string, from, to string) bool { |
| for i := 0; i+1 < len(order); i++ { |
| if order[i] == from && order[i+1] == to { |
| @@ -1533,6 +1539,64 @@ func MoveRevert(g MoveGuard, reason string) func(*Record) error { |
| } |
| } |
| +// The reasons a landing may record for the rollback it cannot yet start |
| +// (Plan 2 Task 10). As with the revert's, each reaches an operator through |
| +// `carlos move status`, so it is a recorded sentence with a copy row, and |
| +// anything else is refused with nothing written. |
| +const ( |
| + // RollbackConfigChanged: the held render's bundle or overlay etags are |
| + // not the ones pinned at readiness (§7.6 configured). |
| + RollbackConfigChanged = "configuration changed during the move" |
| + // RollbackLandingDeadline: landing_deadline passed before restored. |
| + RollbackLandingDeadline = "the landing ran past its deadline" |
| + // RollbackClaimWired: the target runs with CARLOS_HOST_CLAIM on, and |
| + // the held writes a landing makes have no claim selection yet (G16). |
| + RollbackClaimWired = "this box cannot land a move while host claims are on" |
| + // RollbackLandingLayout: the held row is not at the flat database path |
| + // Phase 1 lands at (G11, D10). |
| + RollbackLandingLayout = "the landing row is not at the flat path" |
| + // RollbackInstallMismatch: the built database does not hash to the |
| + // install manifest's sha256, or does not pass its integrity check. |
| + RollbackInstallMismatch = "the installed database does not match the build" |
| +) |
| + |
| +// MoveMarkRollback records on a landing record why the landing stopped |
| +// (§7.6: "any failure from checking to probing, or landing_deadline, |
| +// triggers a rollback"), leaving state and landing_phase as they are: |
| +// Plan 4's rollback transition (§7.8) is what replaces this one call site, |
| +// and until it exists the record carries the reason and the landing pass |
| +// leaves the record alone. The same sentence written again is a no-op; a |
| +// different one over a marked record is a conflict, so the first reason |
| +// stands. reason must be one of the Rollback constants above. |
| +func MoveMarkRollback(g MoveGuard, reason string) func(*Record) error { |
| + return func(rec *Record) error { |
| + if err := g.check(rec); err != nil { |
| + return err |
| + } |
| + if rec.Move.State != MoveLanding { |
| + return fmt.Errorf("%w: a rollback marker is a landing's", ErrMoveConflict) |
| + } |
| + if rec.Move.Reason != "" && rec.Move.Reason != reason { |
| + return fmt.Errorf("%w: the landing already records a reason", ErrMoveConflict) |
| + } |
| + switch reason { |
| + case RollbackConfigChanged: |
| + rec.Move.Reason = RollbackConfigChanged |
| + case RollbackLandingDeadline: |
| + rec.Move.Reason = RollbackLandingDeadline |
| + case RollbackClaimWired: |
| + rec.Move.Reason = RollbackClaimWired |
| + case RollbackLandingLayout: |
| + rec.Move.Reason = RollbackLandingLayout |
| + case RollbackInstallMismatch: |
| + rec.Move.Reason = RollbackInstallMismatch |
| + default: |
| + return fmt.Errorf("provision: rollback reason %q has no recorded sentence", reason) |
| + } |
| + return nil |
| + } |
| +} |
| + |
| // InstanceStoreEnv is the environment name an instance's private object |
| // store hangs off: the record's own Environment, or its Slug when it has |
| // none. Twin of the create door's inline rule (internal/console/ |