provision: StuckReturnUnknownStamp, a return's leftover it cannot account for (Task 3 fix round 1)
A dirty return's install that finds the source's inventory listing paths under a stamp that is neither S nor one the claim shows superseded on this box stops for an operator at local, before anything moves. MoveMarkStuck takes it for a dirty rollback at local only; MoveRetry returns there. Copy row MV-N309 (pending); phase1Conflict speaks it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+24
−9
approved_moves_copy_test.go+1 −1docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+1 −0internal/console/apiinstancemove.go+4 −3internal/console/apiinstancemove_test.go+1 −1internal/provision/move_test.go+5 −1internal/provision/provision.go+12 −3
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 828b8c2c9..ca561578e 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -72,7 +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", |
| + "MV-N307", "MV-N308", "MV-N309", |
| } |
| // 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 fa2369356..d6559f8b5 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -256,6 +256,7 @@ Columns: id, file, text, status. |
| | 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 | |
| +| MV-N309 | internal/provision/provision.go | the source holds files of an installation the return cannot account for | pending | |
| ## Plan 5 |
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index 8b4935397..3d933d7ca 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -889,7 +889,7 @@ 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-N308: the rollback, retry and declare-lost refusals and the |
| +// MV-N300 to MV-N309: 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 |
| @@ -916,14 +916,15 @@ func phase1Conflict(err error) string { |
| msg == "the target holds nothing the move still needs. there is nothing to declare lost", |
| // The reasons Plan 4 records (ruling J9): provision.RollbackDeclaredLost, |
| // StuckBaselineMismatch, StuckRecoveryProbeAttempts, StuckRevertProbeBudget, |
| - // and the shared installer's StuckReturnFilesOccupied and |
| - // StuckReturnInstallMismatch (Task 3). |
| + // and the shared installer's StuckReturnFilesOccupied, |
| + // StuckReturnInstallMismatch and StuckReturnUnknownStamp (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", |
| + msg == "the source holds files of an installation the return cannot account for", |
| 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 2a796f07c..feb92b2f4 100644 |
| --- a/internal/console/apiinstancemove_test.go |
| +++ b/internal/console/apiinstancemove_test.go |
| @@ -1443,7 +1443,7 @@ 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, |
| - provision.StuckReturnFilesOccupied, provision.StuckReturnInstallMismatch} { |
| + provision.StuckReturnFilesOccupied, provision.StuckReturnInstallMismatch, provision.StuckReturnUnknownStamp} { |
| 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 3190b682c..dc6fbdd2d 100644 |
| --- a/internal/provision/move_test.go |
| +++ b/internal/provision/move_test.go |
| @@ -1262,6 +1262,7 @@ func TestStuckReturnInstallReasons(t *testing.T) { |
| for _, c := range []struct{ phase, reason string }{ |
| {RbSourceLocal, StuckReturnFilesOccupied}, |
| {RbSourceConfigured, StuckReturnInstallMismatch}, |
| + {RbSourceLocal, StuckReturnUnknownStamp}, |
| } { |
| rec := at(c.phase, true) |
| if err := MoveMarkStuck(g, c.reason)(rec); err != nil { |
| @@ -1289,6 +1290,9 @@ func TestStuckReturnInstallReasons(t *testing.T) { |
| {RbSourceParking, StuckReturnFilesOccupied, true}, |
| {RbSourceRestored, StuckReturnInstallMismatch, true}, |
| {RbSourceVerified, StuckReturnFilesOccupied, false}, |
| + {RbSourceLocal, StuckReturnUnknownStamp, false}, |
| + {RbSourceConfigured, StuckReturnUnknownStamp, true}, |
| + {RbSourceParking, StuckReturnUnknownStamp, true}, |
| } { |
| rec := at(c.phase, c.dirty) |
| if err := MoveMarkStuck(g, c.reason)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.State != MoveRollingBack { |
| @@ -1297,7 +1301,7 @@ func TestStuckReturnInstallReasons(t *testing.T) { |
| } |
| // 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} { |
| + for _, reason := range []string{StuckReturnFilesOccupied, StuckReturnInstallMismatch, StuckReturnUnknownStamp} { |
| if err := MoveMarkStuck(g, reason)(rev); !errors.Is(err, ErrMoveConflict) || rev.Move.State != MoveReverting { |
| t.Fatalf("a revert with %q: %v", reason, err) |
| } |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index ef808c876..19f083b42 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -1804,6 +1804,12 @@ const ( |
| // 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" |
| + // StuckReturnUnknownStamp: a dirty return's install found the source's |
| + // inventory listing paths under a stamp that is neither the source's own |
| + // S nor one the claim shows superseded on this box (live, unknown, or |
| + // another holder's). Nothing is moved. A rollback whose source is at |
| + // local. |
| + StuckReturnUnknownStamp = "the source holds files of an installation the return cannot account for" |
| ) |
| // The refusals of this section that reach an operator through a door |
| @@ -2004,8 +2010,9 @@ 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); |
| +// - StuckReturnFilesOccupied and StuckReturnUnknownStamp: 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. |
| @@ -2033,6 +2040,8 @@ func MoveMarkStuck(g MoveGuard, reason string) func(*Record) error { |
| why = StuckReturnFilesOccupied |
| case StuckReturnInstallMismatch: |
| why = StuckReturnInstallMismatch |
| + case StuckReturnUnknownStamp: |
| + why = StuckReturnUnknownStamp |
| default: |
| return fmt.Errorf("provision: stuck reason %q has no recorded sentence", reason) |
| } |
| @@ -2048,7 +2057,7 @@ func MoveMarkStuck(g MoveGuard, reason string) func(*Record) error { |
| ok = rb.SourcePhase == RbSourceRestoring || rb.SourcePhase == RbSourceVerified |
| case StuckRecoveryProbeAttempts: |
| ok = rb.SourcePhase == RbSourceProbing |
| - case StuckReturnFilesOccupied: |
| + case StuckReturnFilesOccupied, StuckReturnUnknownStamp: |
| ok = rb.Dirty && rb.SourcePhase == RbSourceLocal |
| case StuckReturnInstallMismatch: |
| ok = rb.Dirty && rb.SourcePhase == RbSourceConfigured |