carlos / platform Public

carlos move status says why a cut did not start

A ready move with a recorded refusal (the cut guard's two, and the
replica layout's MV-N167, which status never printed) now prints
"The cut did not start: <reason>." before M-C12. Copy row MV-N443,
pending.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 6871d2e32386b9c2c2bcb8ad88f0ab8dd3068f73 parent 083a6ea
4 files changed, +27 −2
  • approved_moves_copy_test.go +1 −1
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +2 −1
  • move.go +5 −0
  • move_test.go +19 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 7553d9484..1a3e9dc0f 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -84,7 +84,7 @@ var movesCopyIDs = []string{
"MV-N372", "MV-N373", "MV-N374", "MV-N375", "MV-N376", "MV-N377", "MV-N378", "MV-N379",
"MV-N380", "MV-N381",
"MV-N385", "MV-N386", "MV-N387", "MV-N389", "MV-N390",
- "MV-N440", "MV-N441", "MV-N442",
+ "MV-N440", "MV-N441", "MV-N442", "MV-N443",
}
// 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 1869b4465..4b4977d97 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -398,10 +398,11 @@ The 16 rows added after that review (MV-N184 to MV-N186, MV-N300 to MV-N312) wer
## Moves cut guard
-Paul's ruling of 2026-10-11 (N1.3 in the Plan 7 preflight): the source refuses to start a cut unless the target's latest report states T13, that its box can land the move and roll it back, and unless the source can recover the move itself. MV-N440 is T13's reason, which `carlos move status` shows through M-C4 while the move is preparing. MV-N441 and MV-N442 are the refusals the cut records on the move (MoveBackToReadyRefused, like MV-N167). MV-N443 to MV-N449 are not used.
+Paul's ruling of 2026-10-11 (N1.3 in the Plan 7 preflight): the source refuses to start a cut unless the target's latest report states T13, that its box can land the move and roll it back, and unless the source can recover the move itself. MV-N440 is T13's reason, which `carlos move status` shows through M-C4 while the move is preparing. MV-N441 and MV-N442 are the refusals the cut records on the move (MoveBackToReadyRefused, like MV-N167). MV-N443 is the line `carlos move status` now prints for a ready move with a recorded refusal, MV-N167 included. MV-N444 to MV-N449 are not used.
| id | file | text | status |
|---|---|---|---|
| MV-N440 | internal/host/movesreport.go | this box cannot land the move or roll it back yet | pending |
| MV-N441 | internal/provision/provision.go | the target box cannot land the move or roll it back yet | pending |
| MV-N442 | internal/provision/provision.go | the source box cannot recover the move if the cut fails | pending |
+| MV-N443 | move.go | The cut did not start: <reason>. | pending |
diff --git a/move.go b/move.go
index 670426511..6356dbff4 100644
--- a/move.go
+++ b/move.go
@@ -720,6 +720,11 @@ func (w *moveWatch) observe(a moveAnswer) int {
}
}
case "ready":
+ // A cut the source refused (the cut guard, a replica layout it
+ // cannot cut) is back at ready with the reason recorded.
+ if j.Reason != "" && w.first("refused:"+j.Reason) {
+ moveSay(w.m.out, fmt.Sprintf("The cut did not start: %s.", j.Reason))
+ }
if j.Cut == "manual" {
moveSay(w.m.out, fmt.Sprintf("Ready to cut. Run carlos move cut --host %s when you are ready.", host))
return moveExitWaiting
diff --git a/move_test.go b/move_test.go
index 18848be6b..27f1be224 100644
--- a/move_test.go
+++ b/move_test.go
@@ -15,6 +15,7 @@ import (
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostfence"
"github.com/carlosframework/platform/internal/hostresolve"
+ "github.com/carlosframework/platform/internal/provision"
"io"
"net/http"
"net/http/httptest"
@@ -1070,3 +1071,21 @@ func TestMoveDeclareLost(t *testing.T) {
wantLines(t, out, "declare-lost is not enabled in this deployment yet")
})
}
+
+// The cut guard (Paul's ruling, 2026-10-11): a cut the source refused goes
+// back to ready with the reason recorded on the move, and `carlos move
+// status` says it. A ready move with no reason says nothing more.
+func TestMoveStatusSaysWhyTheCutDidNotStart(t *testing.T) {
+ for _, reason := range []string{provision.RefusedTargetUnarmed, provision.RefusedSourceUnarmed, provision.RefusedReplicaLayout} {
+ c := &fakeMoveConsole{frames: []frame{inFlight("ready", frame{"cut": "manual", "reason": reason}, nil)}}
+ code, out := runMoveAgainst(t, c, "status", "--host", tHost)
+ if code != moveExitWaiting {
+ t.Fatalf("exit %d:\n%s", code, out)
+ }
+ wantLines(t, out, "Move "+tMoveID+" of acme.go.tito.io is ready.", "The cut did not start: "+reason+".", mc12)
+ }
+ c := &fakeMoveConsole{frames: []frame{inFlight("ready", frame{"cut": "manual"}, nil)}}
+ if _, out := runMoveAgainst(t, c, "status", "--host", tHost); strings.Contains(out, "did not start") {
+ t.Fatalf("a ready move with no refusal:\n%s", out)
+ }
+}