carlos / platform Public

moves: the start check accepts .files-gen only for the holder's generation or its cold predecessor

Warm self-links no longer break the admission walk (Astra, Task 4 review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 302665b2bf64785652d3a67244d91ef682c6c961 parent 397df23
3 files changed, +95 −25
  • docs/superpowers/specs/2026-10-02-move-any-app-design.md +5 −2
  • internal/moves/startcheck.go +49 −23
  • internal/moves/startcheck_test.go +41 −0
diff --git a/docs/superpowers/specs/2026-10-02-move-any-app-design.md b/docs/superpowers/specs/2026-10-02-move-any-app-design.md
index 77562a013..26471345f 100644
--- a/docs/superpowers/specs/2026-10-02-move-any-app-design.md
+++ b/docs/superpowers/specs/2026-10-02-move-any-app-design.md
@@ -1888,8 +1888,11 @@ is the region of the holder's current installation:
generation for every probe, R10-3); a lagging marker is read through
`holder.admissions` (§5.1 step 9); and its incarnation to be the
holder's;
-2. `.files-gen`, when the files directory exists, to name the same
- generation and incarnation;
+2. `.files-gen`, when the files directory exists, to name the holder's
+ incarnation and either `holder.generation` or its cold predecessor
+ (the newest cold admission into `holder.generation`, warm self-links
+ skipped), which is the prepared generation a restarted probe was restored
+ from; no older generation counts;
3. `<dbPath>.gen` to name the same generation, exactly: the plain name
carries no location, which the claim's `holder.location` vouches for.
A warm database that does not is from an earlier stay on this box: the
diff --git a/internal/moves/startcheck.go b/internal/moves/startcheck.go
index a569c53da..c7586cdf8 100644
--- a/internal/moves/startcheck.go
+++ b/internal/moves/startcheck.go
@@ -48,27 +48,38 @@ func originOr(g string) string {
return g
}
-// expectedGenerations is the set of generations a marker may name for this
-// holder: holder.generation, as the admission's CAS left it, and the lagging
-// ones. A marker is published once per admission, at Replicate, so after a
-// restarted attempt (a fresh generation restored from the prepared one) or a
-// run of admissions that published nothing, the marker still names an
-// earlier generation. Walking holder.admissions from the newest, each
-// admission whose generation is already accepted adds its predecessor. The
-// origin is hostclaim.RootGeneration here.
-func expectedGenerations(h *hostclaim.Holder) map[string]bool {
+// coldAdmission is the newest admission into generation g that is not a warm
+// self-link (F from F, the warm keep genselect's unfinished skips). Its
+// predecessor is where g came from.
+func coldAdmission(h *hostclaim.Holder, g string) (hostclaim.AdmissionRecord, bool) {
+ for i := len(h.Admissions) - 1; i >= 0; i-- {
+ a := h.Admissions[i]
+ if originOr(a.Generation) != g || a.Mode == "warm" {
+ continue
+ }
+ return a, true
+ }
+ return hostclaim.AdmissionRecord{}, false
+}
+
+// markerGenerations is the set of generations generation.json may name for
+// this holder: holder.generation, as the admission's CAS left it, and the
+// lagging ones. A marker is published once per admission, at Replicate, so
+// after a restarted attempt (a fresh generation restored from the prepared
+// one) or a run of admissions that published nothing, the marker still names
+// an earlier generation. Walking holder.admissions from the newest, skipping
+// warm self-links, each cold admission into an accepted generation adds its
+// predecessor. The origin is hostclaim.RootGeneration here.
+func markerGenerations(h *hostclaim.Holder) map[string]bool {
cur := originOr(h.Generation)
ok := map[string]bool{cur: true}
for {
- var next string
- found := false
- for i := len(h.Admissions) - 1; i >= 0; i-- {
- if originOr(h.Admissions[i].Generation) == cur {
- next, found = originOr(h.Admissions[i].Predecessor), true
- break
- }
+ a, found := coldAdmission(h, cur)
+ if !found {
+ return ok
}
- if !found || ok[next] {
+ next := originOr(a.Predecessor)
+ if ok[next] { // damaged record: predecessors that loop
return ok
}
ok[next] = true
@@ -76,15 +87,31 @@ func expectedGenerations(h *hostclaim.Holder) map[string]bool {
}
}
+// filesGenerationOK reports whether .files-gen may name g: exactly
+// holder.generation, or the cold predecessor of holder.generation (the
+// prepared generation a restarted attempt was restored from, whose files
+// are still the ones installed). Nothing further back counts: files from
+// before the preparation are stale.
+func filesGenerationOK(h *hostclaim.Holder, g string) bool {
+ if g == "" || g == hostclaim.RootGeneration {
+ return false
+ }
+ if g == h.Generation {
+ return true
+ }
+ a, found := coldAdmission(h, originOr(h.Generation))
+ return found && a.Predecessor == g
+}
+
// StartCheck is the §5.1 start check against the claim, run after the
// admission CAS and before every spawn.
//
// 1. marker (generation.json; nil is the origin) names a generation the
// claim expects (holder.generation or, lagging, an earlier link of the
// admission chain) and the holder's incarnation.
-// 2. filesGen, when present, names an expected generation (the same chain:
-// a restarted attempt's files still name the prepared generation) and
-// the holder's incarnation.
+// 2. filesGen, when present, names holder.generation or its cold
+// predecessor (a restarted attempt's files still name the prepared
+// generation; nothing older) and the holder's incarnation.
// 3. dbGen, when not "", is exactly holder.generation: the restore rewrites
// it, so it is current after every restore. A warm database that fails
// here is from an earlier stay; the caller parks it and restores cold.
@@ -96,7 +123,7 @@ func StartCheck(c *hostclaim.Claim, marker *genselect.Marker, filesGen *FilesGen
return ErrStartMarker
}
h := c.Holder
- want := expectedGenerations(h)
+ want := markerGenerations(h)
if marker == nil {
if !want[hostclaim.RootGeneration] {
return ErrStartMarker
@@ -106,8 +133,7 @@ func StartCheck(c *hostclaim.Claim, marker *genselect.Marker, filesGen *FilesGen
return ErrStartMarker
}
if filesGen != nil {
- if filesGen.Op == "" || filesGen.Op == hostclaim.RootGeneration ||
- !want[filesGen.Op] || filesGen.Incarnation != h.Incarnation {
+ if !filesGenerationOK(h, filesGen.Op) || filesGen.Incarnation != h.Incarnation {
return ErrStartFiles
}
}
diff --git a/internal/moves/startcheck_test.go b/internal/moves/startcheck_test.go
index bddf1c4bf..c99281ce0 100644
--- a/internal/moves/startcheck_test.go
+++ b/internal/moves/startcheck_test.go
@@ -83,6 +83,47 @@ func TestStartCheckChainTerminatesOnACycle(t *testing.T) {
}
}
+func TestStartCheckRejectsFilesBeforePreparedGeneration(t *testing.T) {
+ // M was prepared over S; F is the restarted attempt restored from M.
+ // Files from before the preparation are stale, even though S is an
+ // ancestor of the holder's generation.
+ c := startClaim("F",
+ hostclaim.AdmissionRecord{Generation: "M", Predecessor: "S", Mode: "cold"},
+ hostclaim.AdmissionRecord{Generation: "F", Predecessor: "M", Mode: "cold"})
+ if err := StartCheck(c, startMarker("M"), &FilesGen{Op: "S", Incarnation: "inc-1"}, "F"); !errors.Is(err, ErrStartFiles) {
+ t.Errorf("files naming S = %v, want ErrStartFiles", err)
+ }
+ if err := StartCheck(c, startMarker("M"), &FilesGen{Op: "M", Incarnation: "inc-1"}, "F"); err != nil {
+ t.Errorf("files naming M: %v", err)
+ }
+ if err := StartCheck(c, startMarker("M"), &FilesGen{Op: "F", Incarnation: "inc-1"}, "F"); err != nil {
+ t.Errorf("files naming F: %v", err)
+ }
+}
+
+func TestStartCheckLaggingFilesAfterWarmAdmission(t *testing.T) {
+ // Cold F from M, then a warm re-admission of F (F from F): the files
+ // still name M.
+ c := startClaim("F",
+ hostclaim.AdmissionRecord{Generation: "F", Predecessor: "M", Mode: "cold"},
+ hostclaim.AdmissionRecord{Generation: "F", Predecessor: "F", Mode: "warm"})
+ if err := StartCheck(c, startMarker("F"), &FilesGen{Op: "M", Incarnation: "inc-1"}, "F"); err != nil {
+ t.Errorf("files naming M after a warm admission: %v", err)
+ }
+ if err := StartCheck(c, startMarker("F"), &FilesGen{Op: "other", Incarnation: "inc-1"}, "F"); !errors.Is(err, ErrStartFiles) {
+ t.Errorf("files naming another generation = %v, want ErrStartFiles", err)
+ }
+}
+
+func TestStartCheckLaggingMarkerAfterWarmAdmission(t *testing.T) {
+ c := startClaim("F",
+ hostclaim.AdmissionRecord{Generation: "F", Predecessor: "M", Mode: "cold"},
+ hostclaim.AdmissionRecord{Generation: "F", Predecessor: "F", Mode: "warm"})
+ if err := StartCheck(c, startMarker("M"), nil, ""); err != nil {
+ t.Errorf("marker naming M after a warm admission: %v", err)
+ }
+}
+
func TestStartCheckOriginAfterAFirstAdmission(t *testing.T) {
// The first admission left the origin: its predecessor is the origin, so
// an absent marker is lagging, not wrong.