provision: a stuck reason of its own, and Plan 4's reasons spoken by the door (Task 1 fix round 1)
Move.StuckReason (json stuck_reason) is the only reason MoveMarkStuck writes and MoveRetry clears, so Move.Reason keeps a revert's cause and a landing's mark, and Rollback.Reason the rollback's, through a stuck and a retry. MoveRollBack copies the mark and no longer clears it. phase1Conflict passes the four recorded reasons through as their sentences (ruling J9). The copy gate tracks StuckReason as a sentence field. Plan 4's rows renumbered MV-N300 to MV-N306 (Plan 3 owns MV-N184 onward).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+77
−38
approved_moves_copy_scan_test.go+1 −1approved_moves_copy_test.go+1 −1docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+7 −7internal/console/apiinstancemove.go+8 −1internal/console/apiinstancemove_test.go+8 −0internal/provision/move_test.go+31 −15internal/provision/provision.go+21 −13
diff --git a/approved_moves_copy_scan_test.go b/approved_moves_copy_scan_test.go| index ee2b561f9..37edacf3a 100644 |
| --- a/approved_moves_copy_scan_test.go |
| +++ b/approved_moves_copy_scan_test.go |
| @@ -97,7 +97,7 @@ import ( |
| // movesSentenceFields are the struct fields that carry a sentence from the |
| // function that makes it to the one that speaks it. A read of one as a |
| // placeholder is forwarded, not wild, when every write to it is a sentence. |
| -var movesSentenceFields = map[string]bool{"Reason": true} |
| +var movesSentenceFields = map[string]bool{"Reason": true, "StuckReason": true} |
| // fwdMark stands for a value that is a sink somewhere else (a forwarded |
| // parameter, a field read): it is checked where it is made, not here. |
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index f4535eab6..d9d90a27c 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -71,7 +71,7 @@ var movesCopyIDs = []string{ |
| "MV-N169", "MV-N170", "MV-N171", "MV-N172", "MV-N173", "MV-N174", |
| "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-N187", "MV-N188", "MV-N189", "MV-N190", |
| + "MV-N300", "MV-N301", "MV-N302", "MV-N303", "MV-N304", "MV-N305", "MV-N306", |
| } |
| // 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 5abfc92ce..15c91f98e 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -244,13 +244,13 @@ Columns: id, file, text, status. |
| | id | file | text | status | |
| |---|---|---|---| |
| -| MV-N184 | internal/provision/provision.go | the target box was declared lost | pending | |
| -| MV-N185 | internal/provision/provision.go | the source no longer matches what it recorded when it stopped | pending | |
| -| MV-N186 | internal/provision/provision.go | the recovery probe ran out of attempts | pending | |
| -| MV-N187 | internal/provision/provision.go | the source did not answer within 15 minutes of moving back | pending | |
| -| MV-N188 | internal/provision/provision.go | the move has committed. it cannot roll back | pending | |
| -| MV-N189 | internal/provision/provision.go | the move is not stuck. there is nothing to retry | pending | |
| -| MV-N190 | internal/provision/provision.go | the target holds nothing the move still needs. there is nothing to declare lost | pending | |
| +| MV-N300 | internal/provision/provision.go | the target box was declared lost | pending | |
| +| MV-N301 | internal/provision/provision.go | the source no longer matches what it recorded when it stopped | pending | |
| +| MV-N302 | internal/provision/provision.go | the recovery probe ran out of attempts | pending | |
| +| MV-N303 | internal/provision/provision.go | the source did not answer within 15 minutes of moving back | pending | |
| +| 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 | |
| ## Plan 5 |
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index 4a0482b7c..35beb3ce1 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -889,7 +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 |
| -// rollback, retry and declare-lost refusals): never the |
| +// MV-N300 to MV-N306: the rollback, retry and declare-lost refusals and the |
| +// four 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. |
| @@ -913,6 +914,12 @@ 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. |
| + 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", |
| 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 0f1217770..1985d4943 100644 |
| --- a/internal/console/apiinstancemove_test.go |
| +++ b/internal/console/apiinstancemove_test.go |
| @@ -1440,4 +1440,12 @@ func TestPhase1ConflictPassesPlan4Refusals(t *testing.T) { |
| t.Fatalf("phase1Conflict(%v) = %q, want %q", err, got, want) |
| } |
| } |
| + // 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} { |
| + 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 b49d60c4a..943085db7 100644 |
| --- a/internal/provision/move_test.go |
| +++ b/internal/provision/move_test.go |
| @@ -866,15 +866,14 @@ func TestRollBackStartsTheJournal(t *testing.T) { |
| t.Fatalf("from %s: rollback %+v", phase, rb) |
| } |
| } |
| - // A marked landing: the mark is the rollback's reason, whatever the |
| - // caller names (the first reason stands), and Move.Reason is left free |
| - // for a stuck reason. |
| + // A marked landing: the mark is copied to the rollback's reason, whatever |
| + // the caller names (the first reason stands), and stays on the move. |
| for _, reason := range []string{"", RollbackDeclaredLost, RollbackLandingDeadline} { |
| rec := rollbackTestLanding(g, now, LandConfigured) |
| if err := MoveMarkRollback(g, RollbackConfigChanged)(rec); err != nil { |
| t.Fatal(err) |
| } |
| - if err := MoveRollBack(g, rbTestID, reason, now)(rec); err != nil || rec.Move.Rollback.Reason != RollbackConfigChanged || rec.Move.Reason != "" { |
| + if err := MoveRollBack(g, rbTestID, reason, now)(rec); err != nil || rec.Move.Rollback.Reason != RollbackConfigChanged || rec.Move.Reason != RollbackConfigChanged || rec.Move.StuckReason != "" { |
| t.Fatalf("marked, caller says %q: %v %+v", reason, err, rec.Move.Rollback) |
| } |
| } |
| @@ -1146,7 +1145,7 @@ func TestStuckAndRetryReturnToThePhase(t *testing.T) { |
| t.Fatalf("%s: %v", c.phase, err) |
| } |
| m, rb := rec.Move, rec.Move.Rollback |
| - if m.State != MoveStuck || m.Reason != c.reason || rb.StuckAt != c.phase || rb.SourcePhase != c.phase || rb.Reason != RollbackProbeAttempts { |
| + if m.State != MoveStuck || m.StuckReason != c.reason || m.Reason != "" || rb.StuckAt != c.phase || rb.SourcePhase != c.phase || rb.Reason != RollbackProbeAttempts { |
| t.Fatalf("%s: %+v %+v", c.phase, m, rb) |
| } |
| // The same mark again is a replay; another is refused. |
| @@ -1157,7 +1156,7 @@ func TestStuckAndRetryReturnToThePhase(t *testing.T) { |
| if c.reason == other { |
| other = StuckRecoveryProbeAttempts |
| } |
| - if err := MoveMarkStuck(g, other)(rec); !errors.Is(err, ErrMoveConflict) || m.Reason != c.reason { |
| + if err := MoveMarkStuck(g, other)(rec); !errors.Is(err, ErrMoveConflict) || m.StuckReason != c.reason { |
| t.Fatalf("%s another reason: %v", c.phase, err) |
| } |
| // Nothing advances a stuck source. |
| @@ -1169,7 +1168,7 @@ func TestStuckAndRetryReturnToThePhase(t *testing.T) { |
| if err := MoveRetry(g)(rec); err != nil { |
| t.Fatalf("%s retry: %v", c.phase, err) |
| } |
| - if m.State != MoveRollingBack || m.Reason != "" || rb.SourcePhase != c.phase || rb.StuckAt != "" || rb.Reason != RollbackProbeAttempts { |
| + if m.State != MoveRollingBack || m.StuckReason != "" || m.Reason != "" || rb.SourcePhase != c.phase || rb.StuckAt != "" || rb.Reason != RollbackProbeAttempts { |
| t.Fatalf("%s after retry: %+v %+v", c.phase, m, rb) |
| } |
| if err := MoveRetry(g)(rec); !errors.Is(err, ErrMoveConflict) || !strings.HasSuffix(err.Error(), ": "+RefusedNotStuck) { |
| @@ -1196,14 +1195,31 @@ func TestStuckAndRetryReturnToThePhase(t *testing.T) { |
| t.Fatalf("free text: %v", err) |
| } |
| - // A revert sticks with no rollback journal and retries to reverting. |
| + // A marked rollback keeps its cause, on the move and on the journal, |
| + // through a stuck and a retry: only the stuck reason comes and goes. |
| + marked := rollbackTestLanding(g, now, LandProbing) |
| + if err := MoveMarkRollback(g, RollbackProbeAttempts)(marked); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := MoveRollBack(g, rbTestID, "", now)(marked); err != nil { |
| + t.Fatal(err) |
| + } |
| + marked.Move.Rollback.TargetPhase, marked.Move.Rollback.SourcePhase = RbTargetReleased, RbSourceProbing |
| + if err := MoveMarkStuck(g, StuckRecoveryProbeAttempts)(marked); err != nil || marked.Move.StuckReason != StuckRecoveryProbeAttempts || marked.Move.Reason != RollbackProbeAttempts || marked.Move.Rollback.Reason != RollbackProbeAttempts { |
| + t.Fatalf("marked rollback stuck: %v %+v", err, marked.Move) |
| + } |
| + if err := MoveRetry(g)(marked); err != nil || marked.Move.StuckReason != "" || marked.Move.Reason != RollbackProbeAttempts || marked.Move.Rollback.Reason != RollbackProbeAttempts { |
| + t.Fatalf("marked rollback retry: %v %+v", err, marked.Move) |
| + } |
| + |
| + // A revert sticks with no rollback journal and retries to reverting; its |
| + // own cause survives both. |
| rec := &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{StuckRevertProbeBudget, StuckBaselineMismatch} { |
| - rec.Move.State, rec.Move.Reason = MoveReverting, RevertCutDeadline |
| - if err := MoveMarkStuck(g, reason)(rec); err != nil || rec.Move.State != MoveStuck || rec.Move.Reason != reason || rec.Move.Rollback != nil { |
| + if err := MoveMarkStuck(g, reason)(rec); err != nil || rec.Move.State != MoveStuck || rec.Move.StuckReason != reason || rec.Move.Reason != RevertCutDeadline || rec.Move.Rollback != nil { |
| t.Fatalf("revert %q: %v %+v", reason, err, rec.Move) |
| } |
| - if err := MoveRetry(g)(rec); err != nil || rec.Move.State != MoveReverting || rec.Move.Reason != "" || rec.Move.Rollback != nil || rec.Move.CutPhase != CutStaged { |
| + if err := MoveRetry(g)(rec); err != nil || rec.Move.State != MoveReverting || rec.Move.StuckReason != "" || rec.Move.Reason != RevertCutDeadline || rec.Move.Rollback != nil || rec.Move.CutPhase != CutStaged { |
| t.Fatalf("revert retry: %v %+v", err, rec.Move) |
| } |
| } |
| @@ -1223,7 +1239,7 @@ func TestStuckAndRetryReturnToThePhase(t *testing.T) { |
| } |
| // A stuck rollback that records no phase is not retried blind. |
| rec = at(RbSourceProbing) |
| - rec.Move.State, rec.Move.Reason = MoveStuck, StuckRecoveryProbeAttempts |
| + rec.Move.State, rec.Move.StuckReason = MoveStuck, StuckRecoveryProbeAttempts |
| if err := MoveRetry(g)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.State != MoveStuck { |
| t.Fatalf("retry with no stuck phase: %v", err) |
| } |
| @@ -1342,16 +1358,16 @@ func TestRollbackJournalThroughTheRecordWrite(t *testing.T) { |
| if got.Region != "ie" || got.Move.State != MoveRollingBack || got.Move.Rollback.ID != rbTestID || got.Move.Rollback.Reason != RollbackDeclaredLost || got.Move.Rollback.TargetPhase != RbTargetLost || got.Move.Rollback.LossRef != ref || got.Move.Rollback.LostAt == nil { |
| t.Fatalf("read back: %+v %+v", got.Move, got.Move.Rollback) |
| } |
| - raw, err := json.Marshal(MoveState{ReopenedAt: &now, Rollback: &MoveRollback{StuckAt: RbSourceProbing, LossRef: ref, LostAt: &now}}) |
| + raw, err := json.Marshal(MoveState{ReopenedAt: &now, StuckReason: StuckRecoveryProbeAttempts, Rollback: &MoveRollback{StuckAt: RbSourceProbing, LossRef: ref, LostAt: &now}}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - for _, key := range []string{`"reopened_at"`, `"stuck_at":"probing"`, `"loss_ref"`, `"lost_at"`} { |
| + for _, key := range []string{`"reopened_at"`, `"stuck_reason":"the recovery probe ran out of attempts"`, `"stuck_at":"probing"`, `"loss_ref"`, `"lost_at"`} { |
| if !strings.Contains(string(raw), key) { |
| t.Fatalf("%s missing from %s", key, raw) |
| } |
| } |
| - if raw, _ := json.Marshal(MoveState{Rollback: &MoveRollback{}}); strings.Contains(string(raw), "stuck_at") || strings.Contains(string(raw), "reopened_at") || strings.Contains(string(raw), "loss_ref") { |
| + if raw, _ := json.Marshal(MoveState{Rollback: &MoveRollback{}}); strings.Contains(string(raw), "stuck_at") || strings.Contains(string(raw), "stuck_reason") || strings.Contains(string(raw), "reopened_at") || strings.Contains(string(raw), "loss_ref") { |
| t.Fatalf("the new keys are not omitempty: %s", raw) |
| } |
| } |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index b8645765c..224100466 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -975,6 +975,12 @@ type MoveState struct { |
| Rollback *MoveRollback `json:"rollback,omitempty"` |
| Reason string `json:"reason,omitempty"` |
| + // StuckReason is why a stuck move waits for an operator (§7.8 stuck), |
| + // a recorded sentence that MoveMarkStuck writes and MoveRetry clears. |
| + // It is a field of its own so that Reason keeps the move's cause (a |
| + // revert's, a landing's mark) and Rollback.Reason the rollback's |
| + // through a stuck and a retry. |
| + StuckReason string `json:"stuck_reason,omitempty"` |
| // Relocation is the durable pre-flip conversion decision |
| // (move_relocation.go). It never installs anything. |
| @@ -1776,9 +1782,9 @@ func MoveClose(g MoveGuard, now time.Time) func(*Record) error { |
| const RollbackDeclaredLost = "the target box was declared lost" |
| // The reasons a move may record when it stops for an operator (§7.8 stuck, |
| -// §7.4 reverting). Each reaches `carlos move status` through Move.Reason, |
| -// so each is a recorded sentence with a copy row, and each belongs to the |
| -// phases MoveMarkStuck names for it. |
| +// §7.4 reverting). Each reaches `carlos move status` through |
| +// Move.StuckReason, so each is a recorded sentence with a copy row, and each |
| +// belongs to the phases MoveMarkStuck names for it. |
| const ( |
| // StuckBaselineMismatch: the source's files or timers do not hash to |
| // what it recorded when it stopped (INV-4). A rollback's clean return |
| @@ -1818,9 +1824,9 @@ const rollbackReleaseBudget = 15 * time.Minute |
| // rollback's reason is the landing's recorded mark when there is one (the |
| // first reason stands, as in MoveMarkRollback), else reason, which must be |
| // a recorded Rollback constant (a reason given beside a mark must be one |
| -// too, though the mark wins). Move.Reason is cleared: during a rollback |
| -// it carries only a stuck reason. LandingPhase and Landing are kept (the |
| -// target's release reads them). A replay finds rolling_back and is an |
| +// too, though the mark wins). The mark stays on Move.Reason: it is copied, |
| +// not moved. LandingPhase and Landing are kept (the target's release reads |
| +// them). A replay finds rolling_back and is an |
| // ErrMoveConflict; the caller re-reads. |
| func MoveRollBack(g MoveGuard, rbID, reason string, now time.Time) func(*Record) error { |
| return func(rec *Record) error { |
| @@ -1872,7 +1878,6 @@ func MoveRollBack(g MoveGuard, rbID, reason string, now time.Time) func(*Record) |
| rb.RequestedAt, rb.ReleaseDeadline = &t, &d |
| rec.Region = m.From |
| m.State, m.Rollback = MoveRollingBack, rb |
| - m.Reason = "" |
| return nil |
| } |
| } |
| @@ -1982,7 +1987,9 @@ func MoveRollbackSourcePhase(g MoveGuard, from, to string, stamp func(*MoveRollb |
| // MoveMarkStuck stops a recovery for an operator (§7.8 stuck, §7.4 |
| // reverting): rolling_back or reverting to stuck, with a recorded reason |
| -// in Move.Reason. Each reason belongs to its phases: |
| +// in Move.StuckReason, the one field it writes besides the state and |
| +// StuckAt (Move.Reason and Rollback.Reason keep the cause). Each reason |
| +// belongs to its phases: |
| // |
| // - StuckBaselineMismatch: a rollback whose source is at restoring or |
| // verified (a clean return's verification, INV-4), or a revert (its |
| @@ -2012,7 +2019,7 @@ func MoveMarkStuck(g MoveGuard, reason string) func(*Record) error { |
| default: |
| return fmt.Errorf("provision: stuck reason %q has no recorded sentence", reason) |
| } |
| - if m.State == MoveStuck && m.Reason == why { |
| + if m.State == MoveStuck && m.StuckReason == why { |
| return nil |
| } |
| ok := false |
| @@ -2036,7 +2043,7 @@ func MoveMarkStuck(g MoveGuard, reason string) func(*Record) error { |
| m.Rollback.StuckAt = m.Rollback.SourcePhase |
| } |
| m.State = MoveStuck |
| - m.Reason = why |
| + m.StuckReason = why |
| return nil |
| } |
| } |
| @@ -2044,8 +2051,9 @@ func MoveMarkStuck(g MoveGuard, reason string) func(*Record) error { |
| // MoveRetry is the operator's --retry (§7.8, Task 7): stuck back to the |
| // recovery it stopped. A rollback returns to rolling_back with the source |
| // at the phase StuckAt names, so a mismatch verifies again and a probe |
| -// probes again; a revert (no Rollback) returns to reverting. It clears the |
| -// stuck reason (Move.Reason) and StuckAt, never the rollback's own reason. |
| +// probes again; a revert (no Rollback) returns to reverting. It clears |
| +// Move.StuckReason and StuckAt only: the move's cause (Move.Reason) and the |
| +// rollback's (Rollback.Reason) stay. |
| func MoveRetry(g MoveGuard) func(*Record) error { |
| return func(rec *Record) error { |
| if err := g.check(rec); err != nil { |
| @@ -2063,7 +2071,7 @@ func MoveRetry(g MoveGuard) func(*Record) error { |
| } else { |
| m.State = MoveReverting |
| } |
| - m.Reason = "" |
| + m.StuckReason = "" |
| return nil |
| } |
| } |