carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 35834df792a79f7a5d0e563f654d916e1b3fc05a parent 49ada90
7 files changed, +77 −38
  • approved_moves_copy_scan_test.go +1 −1
  • approved_moves_copy_test.go +1 −1
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +7 −7
  • internal/console/apiinstancemove.go +8 −1
  • internal/console/apiinstancemove_test.go +8 −0
  • internal/provision/move_test.go +31 −15
  • internal/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
}
}