carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 8673e1c71aee0d97517da3d2a9df86f8d4ba729f parent 3666bf0
8 files changed, +262 −2
  • approved_moves_copy_test.go +4 −1
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +9 −1
  • internal/dbverify/dbverify.go +36 −0
  • internal/dbverify/dbverify_test.go +63 −0
  • internal/hostbox/restore.go +19 −0
  • internal/hostbox/restore_test.go +27 −0
  • internal/provision/move_test.go +40 −0
  • internal/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/