carlos / platform Public

provision: the dirty return install's two stuck reasons (Plan 4 Task 3, ruling J13)

A dirty return's install refuses into stuck, never into a landing's
rollback: StuckReturnFilesOccupied at local (the files paths still hold
what the return did not write after the source's own pre-move files were
parked) and StuckReturnInstallMismatch at configured (the return's build
does not verify). MoveMarkStuck takes each at its phase of a dirty
rollback only; MoveRetry returns to it. Copy rows MV-N307 and MV-N308
(pending), and phase1Conflict speaks both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 47c1742f0f62be4b3ea7de60df8872efa6e45078 parent 2dbec05
6 files changed, +93 −5
  • approved_moves_copy_test.go +1 −0
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +2 −0
  • internal/console/apiinstancemove.go +8 −4
  • internal/console/apiinstancemove_test.go +2 −1
  • internal/provision/move_test.go +59 −0
  • internal/provision/provision.go +21 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index d6ada9d46..373391082 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -72,6 +72,7 @@ var movesCopyIDs = []string{
"MV-N175", "MV-N176", "MV-N177", "MV-N178", "MV-N179", "MV-N180", "MV-N181",
"MV-N182", "MV-N183", "MV-N184", "MV-N185", "MV-N186",
"MV-N300", "MV-N301", "MV-N302", "MV-N303", "MV-N304", "MV-N305", "MV-N306",
+ "MV-N307", "MV-N308",
}
// copyRow is one row of the record. The File cell is the file the sentence is
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 3147cb7f9..fa2369356 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -254,6 +254,8 @@ Columns: id, file, text, status.
| MV-N304 | internal/provision/provision.go | the move has committed. it cannot roll back | pending |
| MV-N305 | internal/provision/provision.go | the move is not stuck. there is nothing to retry | pending |
| MV-N306 | internal/provision/provision.go | the target holds nothing the move still needs. there is nothing to declare lost | pending |
+| MV-N307 | internal/provision/provision.go | the files directory holds files the return did not write | pending |
+| MV-N308 | internal/provision/provision.go | the returned database does not match the build | pending |
## Plan 5
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go
index 35beb3ce1..8b4935397 100644
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
@@ -889,8 +889,8 @@ func legacyAbortOf(seen provision.MoveState) func(*provision.Record) error {
// phase1Conflict maps a provision transition refusal to its recorded
// sentence (copy record MV-N12 to MV-N16, MV-N38 to MV-N46, and Plan 4's
-// MV-N300 to MV-N306: the rollback, retry and declare-lost refusals and the
-// four rollback and stuck reasons): never the
+// MV-N300 to MV-N308: the rollback, retry and declare-lost refusals and the
+// rollback and stuck reasons): never the
// "provision: move state conflict:" prefix, never an em dash. A refusal
// this table does not know answers a fixed sentence, so a new provision
// sentence cannot reach an operator unreviewed.
@@ -914,12 +914,16 @@ func phase1Conflict(err error) string {
msg == "the move has committed. it cannot roll back",
msg == "the move is not stuck. there is nothing to retry",
msg == "the target holds nothing the move still needs. there is nothing to declare lost",
- // The four reasons Plan 4 records (ruling J9): provision.RollbackDeclaredLost,
- // StuckBaselineMismatch, StuckRecoveryProbeAttempts and StuckRevertProbeBudget.
+ // The reasons Plan 4 records (ruling J9): provision.RollbackDeclaredLost,
+ // StuckBaselineMismatch, StuckRecoveryProbeAttempts, StuckRevertProbeBudget,
+ // and the shared installer's StuckReturnFilesOccupied and
+ // StuckReturnInstallMismatch (Task 3).
msg == "the target box was declared lost",
msg == "the source no longer matches what it recorded when it stopped",
msg == "the recovery probe ran out of attempts",
msg == "the source did not answer within 15 minutes of moving back",
+ msg == "the files directory holds files the return did not write",
+ msg == "the returned database does not match the build",
strings.HasPrefix(msg, "the record's move is ") && strings.Contains(msg, " for incarnation "),
strings.HasPrefix(msg, "a move to ") && strings.Contains(msg, " is already "),
strings.HasPrefix(msg, "the instance is already in "),
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go
index 1985d4943..2a796f07c 100644
--- a/internal/console/apiinstancemove_test.go
+++ b/internal/console/apiinstancemove_test.go
@@ -1442,7 +1442,8 @@ func TestPhase1ConflictPassesPlan4Refusals(t *testing.T) {
}
// Ruling J9 (fix round 1): the four reasons Plan 4 records are spoken as
// their recorded sentences too, not the fixed one.
- for _, reason := range []string{provision.RollbackDeclaredLost, provision.StuckBaselineMismatch, provision.StuckRecoveryProbeAttempts, provision.StuckRevertProbeBudget} {
+ for _, reason := range []string{provision.RollbackDeclaredLost, provision.StuckBaselineMismatch, provision.StuckRecoveryProbeAttempts, provision.StuckRevertProbeBudget,
+ provision.StuckReturnFilesOccupied, provision.StuckReturnInstallMismatch} {
err := fmt.Errorf("%w: %s", provision.ErrMoveConflict, reason)
if got := phase1Conflict(err); got != reason {
t.Fatalf("phase1Conflict(%v) = %q, want %q", err, got, reason)
diff --git a/internal/provision/move_test.go b/internal/provision/move_test.go
index 943085db7..3190b682c 100644
--- a/internal/provision/move_test.go
+++ b/internal/provision/move_test.go
@@ -1245,6 +1245,65 @@ func TestStuckAndRetryReturnToThePhase(t *testing.T) {
}
}
+// The dirty return's install (Plan 4 Task 3, ruling J13) refuses into stuck,
+// never into a landing's rollback: the files paths still occupied after the
+// source's own pre-move files were parked stop it at local, and a build that
+// does not verify stops it at configured (where the database is installed).
+// Each reason is its phase's own, for a dirty return only, and a retry goes
+// back to that phase.
+func TestStuckReturnInstallReasons(t *testing.T) {
+ g := cutTestGuard()
+ now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ at := func(phase string, dirty bool) *Record {
+ rec := rollingBackRecord(t, g, now)
+ rec.Move.Rollback.TargetPhase, rec.Move.Rollback.SourcePhase, rec.Move.Rollback.Dirty = RbTargetReturned, phase, dirty
+ return rec
+ }
+ for _, c := range []struct{ phase, reason string }{
+ {RbSourceLocal, StuckReturnFilesOccupied},
+ {RbSourceConfigured, StuckReturnInstallMismatch},
+ } {
+ rec := at(c.phase, true)
+ if err := MoveMarkStuck(g, c.reason)(rec); err != nil {
+ t.Fatalf("%s: %v", c.phase, err)
+ }
+ m, rb := rec.Move, rec.Move.Rollback
+ if m.State != MoveStuck || m.StuckReason != c.reason || rb.StuckAt != c.phase || rb.Reason != RollbackProbeAttempts {
+ t.Fatalf("%s: %+v %+v", c.phase, m, rb)
+ }
+ if err := MoveMarkStuck(g, c.reason)(rec); err != nil {
+ t.Fatalf("%s replay: %v", c.phase, err)
+ }
+ if err := MoveRetry(g)(rec); err != nil || m.State != MoveRollingBack || rb.SourcePhase != c.phase || m.StuckReason != "" {
+ t.Fatalf("%s retry: %v %+v %+v", c.phase, err, m, rb)
+ }
+ }
+ for _, c := range []struct {
+ phase, reason string
+ dirty bool
+ }{
+ {RbSourceLocal, StuckReturnFilesOccupied, false},
+ {RbSourceConfigured, StuckReturnInstallMismatch, false},
+ {RbSourceConfigured, StuckReturnFilesOccupied, true},
+ {RbSourceLocal, StuckReturnInstallMismatch, true},
+ {RbSourceParking, StuckReturnFilesOccupied, true},
+ {RbSourceRestored, StuckReturnInstallMismatch, true},
+ {RbSourceVerified, StuckReturnFilesOccupied, false},
+ } {
+ rec := at(c.phase, c.dirty)
+ if err := MoveMarkStuck(g, c.reason)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.State != MoveRollingBack {
+ t.Fatalf("%s (dirty %v) with %q: %v", c.phase, c.dirty, c.reason, err)
+ }
+ }
+ // A revert installs nothing, so neither reason is a revert's.
+ rev := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveReverting, CutPhase: CutStaged, Reason: RevertCutDeadline}}
+ for _, reason := range []string{StuckReturnFilesOccupied, StuckReturnInstallMismatch} {
+ if err := MoveMarkStuck(g, reason)(rev); !errors.Is(err, ErrMoveConflict) || rev.Move.State != MoveReverting {
+ t.Fatalf("a revert with %q: %v", reason, err)
+ }
+ }
+}
+
func TestRolledBackCloseNeedsAllThreeStamps(t *testing.T) {
g := cutTestGuard()
now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 224100466..ef808c876 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -1795,6 +1795,15 @@ const (
StuckRecoveryProbeAttempts = "the recovery probe ran out of attempts"
// StuckRevertProbeBudget: a revert's probes failed for 15 minutes.
StuckRevertProbeBudget = "the source did not answer within 15 minutes of moving back"
+ // StuckReturnFilesOccupied: a dirty return's install (Plan 4 Task 3,
+ // ruling J13) found the files directory or <host>.env holding what it
+ // did not write after the source's own pre-move files were parked. A
+ // rollback whose source is at local.
+ StuckReturnFilesOccupied = "the files directory holds files the return did not write"
+ // StuckReturnInstallMismatch: the database a dirty return built from its
+ // lane does not verify. A rollback whose source is at configured, where
+ // the database is installed.
+ StuckReturnInstallMismatch = "the returned database does not match the build"
)
// The refusals of this section that reach an operator through a door
@@ -1995,6 +2004,10 @@ func MoveRollbackSourcePhase(g MoveGuard, from, to string, stamp func(*MoveRollb
// verified (a clean return's verification, INV-4), or a revert (its
// verify);
// - StuckRecoveryProbeAttempts: a rollback whose source is at probing;
+// - StuckReturnFilesOccupied: a dirty rollback whose source is at local
+// (the shared installer, Plan 4 Task 3);
+// - StuckReturnInstallMismatch: a dirty rollback whose source is at
+// configured;
// - StuckRevertProbeBudget: a revert.
//
// A rollback records the source phase in StuckAt, which MoveRetry returns
@@ -2016,6 +2029,10 @@ func MoveMarkStuck(g MoveGuard, reason string) func(*Record) error {
why = StuckRecoveryProbeAttempts
case StuckRevertProbeBudget:
why = StuckRevertProbeBudget
+ case StuckReturnFilesOccupied:
+ why = StuckReturnFilesOccupied
+ case StuckReturnInstallMismatch:
+ why = StuckReturnInstallMismatch
default:
return fmt.Errorf("provision: stuck reason %q has no recorded sentence", reason)
}
@@ -2031,6 +2048,10 @@ func MoveMarkStuck(g MoveGuard, reason string) func(*Record) error {
ok = rb.SourcePhase == RbSourceRestoring || rb.SourcePhase == RbSourceVerified
case StuckRecoveryProbeAttempts:
ok = rb.SourcePhase == RbSourceProbing
+ case StuckReturnFilesOccupied:
+ ok = rb.Dirty && rb.SourcePhase == RbSourceLocal
+ case StuckReturnInstallMismatch:
+ ok = rb.Dirty && rb.SourcePhase == RbSourceConfigured
}
}
case MoveReverting: