moves cut guard: the target states T13 and the source refuses unarmed cuts
Paul's ruling of 2026-10-11 (N1.3 in the Plan 7 preflight), behind CARLOS_MOVES_PHASE1. - The target's report carries T13, landingArmed && rollbackTargetArmed judged as the report is written. T13 joins AllChecks, so an unarmed target fails readiness and `carlos move status` shows why while the move is preparing. TargetReport.Armed fails closed: no report, an older box's report with no T13, or a failing T13 is unarmed. - The cut refuses first at requested (before the source round takes the staging lease) and last at admitted (the step before stopping) unless the target's latest report is armed and recoverySourceArmed holds on the source. The move goes back to ready, unpinned, with RefusedTargetUnarmed or RefusedSourceUnarmed recorded; nothing is held, fenced, stopped, checkpointed or sealed. - decideReady pins no move again while the source cannot recover, so an automatic cut does not loop through the refusal. - The cut and Phase 1 harnesses now arm both sides with stand-ins (armStandIns); one recovery test that assumed the production-unarmed source disarms it explicitly. - Copy rows MV-N440 to MV-N442, pending.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
13 files changed,
+553
−10
approved_moves_copy_test.go+1 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+10 −0internal/host/movescut.go+42 −0internal/host/movescut_test.go+3 −0internal/host/movescutguard_test.go+359 −0internal/host/movesphase1.go+10 −0internal/host/movesphase1_harness_test.go+3 −0internal/host/movesrecover_test.go+7 −2internal/host/movesreport.go+17 −1internal/moves/readiness.go+20 −2internal/moves/readiness_test.go+40 −3internal/provision/move_test.go+19 −0internal/provision/provision.go+22 −2
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index ebaf2c325..7553d9484 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -84,6 +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", |
| } |
| // 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 16921bf60..1869b4465 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -395,3 +395,13 @@ Built, but the gate could not read the row (3). Paul adopted three notation rule |
| - MV-N146: the note moved from the Text cell to the File cell (`internal/moves/readiness.go (the <which> of MV-N68)`), and the text is the fill-in `the disk's per-file sync`, which `readiness.go` says as one literal. |
| The 16 rows added after that review (MV-N184 to MV-N186, MV-N300 to MV-N312) were approved by Paul on 2026-10-09 with no edits. |
| + |
| +## 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. |
| + |
| +| 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 | |
diff --git a/internal/host/movescut.go b/internal/host/movescut.go| index b373f2e85..443cb026c 100644 |
| --- a/internal/host/movescut.go |
| +++ b/internal/host/movescut.go |
| @@ -217,6 +217,9 @@ func (c *cutRun) revert(ctx context.Context, reason string) (bool, error) { |
| // round rewrites. |
| func (c *cutRun) requested(ctx context.Context) (bool, error) { |
| cfg := c.cfg() |
| + if refused, err := c.refuseUnarmed(ctx); refused || err != nil { |
| + return refused, err |
| + } |
| sr, published, err := c.w.sourceRound(ctx, c.s, c.rec, c.fleet) |
| if err != nil || published == nil { |
| return false, err |
| @@ -261,9 +264,48 @@ func (c *cutRun) admitted(ctx context.Context) (bool, error) { |
| if refused, err := c.refuseHolder(ctx); refused || err != nil { |
| return refused, err |
| } |
| + if refused, err := c.refuseUnarmed(ctx); refused || err != nil { |
| + return refused, err |
| + } |
| return c.advance(ctx, provision.CutAdmitted, provision.CutStopping, nil) |
| } |
| +// refuseUnarmed is the cut guard (Paul's ruling, 2026-10-11): a cut starts |
| +// only when the target's latest report states that its box can land the |
| +// move and roll it back (T13, moves.TargetReport.Armed) and this box can |
| +// recover the move if the cut fails (recoverySourceArmed). Otherwise the |
| +// cut goes back to ready with the reason recorded, before this box takes |
| +// the staging lease, holds or stops anything, or seals the claim: it runs |
| +// first at requested and last at admitted, the step before stopping. It |
| +// fails closed: no report, or an older box's report with no T13, is |
| +// unarmed. The refusal stands while the cause does: decideReady pins no |
| +// move again while this box cannot recover, and the target's failing T13 |
| +// keeps Ready from pinning it (§7.3), so nothing loops and the operator |
| +// reads why in `carlos move status`. |
| +func (c *cutRun) refuseUnarmed(ctx context.Context) (bool, error) { |
| + reason := "" |
| + if !recoverySourceArmed(c.cfg()) { |
| + reason = provision.RefusedSourceUnarmed |
| + } else { |
| + target, err := readTargetReport(ctx, c.s, c.rec) |
| + if err != nil { |
| + return false, err |
| + } |
| + if !target.Armed() { |
| + reason = provision.RefusedTargetUnarmed |
| + } |
| + } |
| + if reason == "" { |
| + return false, nil |
| + } |
| + log.Printf("moves: %s: cut %s refused: %s", c.rec.Host, c.g.ID, reason) |
| + ok, err := c.cutTransition(ctx, "the refusal", provision.MoveBackToReadyRefused(c.g, reason)) |
| + if ok { |
| + nudgeSerial(ctx, c.s) |
| + } |
| + return ok, err |
| +} |
| + |
| // refuseHolder reads the claim fresh and, when its holder is not at the |
| // activator's lease location (holderSupported), sends the cut back to |
| // ready with RefusedReplicaLayout recorded (ruling T5-lease). The |
diff --git a/internal/host/movescut_test.go b/internal/host/movescut_test.go| index a94566d51..0d8226ce0 100644 |
| --- a/internal/host/movescut_test.go |
| +++ b/internal/host/movescut_test.go |
| @@ -140,6 +140,9 @@ func newCutHarness(t *testing.T) *cutHarness { |
| } |
| return nil |
| } |
| + // The cut guard: the source can recover a failed cut, as every box |
| + // will once claim Plan 12 lands (armStandIns). |
| + armStandIns(&h.cfg, h.s, roleSource) |
| return h |
| } |
diff --git a/internal/host/movescutguard_test.go b/internal/host/movescutguard_test.go| new file mode 100644 |
| index 000000000..84b3cb034 |
| --- /dev/null |
| +++ b/internal/host/movescutguard_test.go |
| @@ -0,0 +1,359 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The cut guard (Paul's ruling, 2026-10-11): the source refuses to start a |
| +// cut unless the target's latest report states that its box can land the |
| +// move and roll it back (T13), and unless the source itself can recover. |
| +// The refusal comes before anything stops, the writer's lease is taken or |
| +// the claim is sealed, and the move stays ready with the reason recorded. |
| + |
| +// errStandIn is what a harness stand-in answers. The stand-ins arm a box |
| +// so that the arming predicates hold, as they do once claim Plan 12 lands; |
| +// a test that reaches one has run a step its harness never wired. |
| +var errStandIn = errors.New("harness stand-in: this hook is not wired in this harness") |
| + |
| +func standIn(name string) error { return fmt.Errorf("%s: %w", name, errStandIn) } |
| + |
| +// armStandIns fills every hook landingArmed, rollbackTargetArmed and |
| +// recoverySourceArmed read that c lacks, with the park fakes (as |
| +// armParkFakes arms them, for role) and a stand-in for each other hook. A hook c already has is |
| +// kept, so a harness's own fakes win. |
| +func armStandIns(c *moveCfg, s release.Store, role string) { |
| + if c.Guard.DataDir == "" { |
| + c.Guard = hostguard.Guard{DataDir: c.DataDir} |
| + } |
| + if c.Local.DataDir == "" { |
| + c.Local = park.Local{DataDir: c.DataDir, Box: c.Box} |
| + } |
| + if c.ParkLeases == nil { |
| + c.ParkLeases = &fakeParkLeases{} |
| + } |
| + if c.ParkView == nil { |
| + c.ParkView = recordView(s, c.Region, role) |
| + } |
| + if c.ParkAuthorize == nil { |
| + c.ParkAuthorize = allowParkAuthorize |
| + } |
| + if c.ParkRunHeld == nil { |
| + c.ParkRunHeld = testRunHeld |
| + } |
| + if c.ParkLocalAuthorize == nil { |
| + c.ParkLocalAuthorize = allowLocalAuthorize |
| + } |
| + if c.OwnLease == nil { |
| + c.OwnLease = func(context.Context, string, string) (park.HeldLease, error) { return nil, standIn("OwnLease") } |
| + } |
| + if c.RenderHeld == nil { |
| + c.RenderHeld = func(context.Context, release.Store, registry.Route, string, func(string) error) (appconfig.HeldRender, error) { |
| + return appconfig.HeldRender{}, standIn("RenderHeld") |
| + } |
| + } |
| + if c.ReplaceTimers == nil { |
| + c.ReplaceTimers = func(context.Context, string, []registry.Timer) error { return standIn("ReplaceTimers") } |
| + } |
| + if c.SetReplication == nil { |
| + c.SetReplication = func(string, string) error { return standIn("SetReplication") } |
| + } |
| + if c.SetNextDue == nil { |
| + c.SetNextDue = func(string, int64) error { return standIn("SetNextDue") } |
| + } |
| + if c.RemoveVerifyEntry == nil { |
| + c.RemoveVerifyEntry = func(context.Context, release.Store, string) error { return standIn("RemoveVerifyEntry") } |
| + } |
| + if c.WriteGen == nil { |
| + c.WriteGen = func(string, string) error { return standIn("WriteGen") } |
| + } |
| + if c.ReleaseReady == nil { |
| + c.ReleaseReady = func(context.Context, release.Store, provision.Record, string) moves.Check { |
| + return moves.Check{Reason: standIn("ReleaseReady").Error()} |
| + } |
| + } |
| + if c.HandHold == nil { |
| + c.HandHold = func(string, string, string, string, func() error) (bool, error) { return false, standIn("HandHold") } |
| + } |
| + if c.ReleaseHeld == nil { |
| + c.ReleaseHeld = func(string, string) (bool, error) { return false, standIn("ReleaseHeld") } |
| + } |
| + if c.Timers == nil { |
| + c.Timers = func(string) ([]registry.Timer, error) { return nil, standIn("Timers") } |
| + } |
| + if role != roleTarget { |
| + return |
| + } |
| + if c.DeleteHeld == nil { |
| + c.DeleteHeld = func(string, string) (bool, error) { return false, standIn("DeleteHeld") } |
| + } |
| + if c.Fence == nil { |
| + c.Fence = func(context.Context, string, uint64) error { return standIn("Fence") } |
| + } |
| + if c.TimersDrained == nil { |
| + c.TimersDrained = func(context.Context, string) error { return standIn("TimersDrained") } |
| + } |
| + if c.Evidence == nil { |
| + c.Evidence = func(context.Context, string, string, []string) (moves.StoppedReport, error) { |
| + return moves.StoppedReport{}, standIn("Evidence") |
| + } |
| + } |
| + if c.FinalCheckpoint == nil { |
| + c.FinalCheckpoint = func(context.Context, string, string, hostclaim.Holder, func(context.Context) error) error { |
| + return standIn("FinalCheckpoint") |
| + } |
| + } |
| + if c.LocalPosition == nil { |
| + c.LocalPosition = func(string) (uint64, error) { return 0, standIn("LocalPosition") } |
| + } |
| +} |
| + |
| +// unarmedTarget is the cut harness's target with the landing or the |
| +// rollback left unwired, as every box is in production until claim Plan 12. |
| +func (h *cutHarness) unarmedTarget(t *testing.T, unarm func(*moveCfg)) moveCfg { |
| + t.Helper() |
| + c := h.target() |
| + if !landingArmed(c) || !rollbackTargetArmed(c) { |
| + t.Fatal("fixture: the harness's target is not armed") |
| + } |
| + unarm(&c) |
| + if landingArmed(c) && rollbackTargetArmed(c) { |
| + t.Fatal("fixture: the target is still armed") |
| + } |
| + return c |
| +} |
| + |
| +// nothingStopped fails unless the move is ready again with reason recorded |
| +// and the pass did nothing irreversible: no hold, fence, drain, stop |
| +// evidence (which reads the writer's lease) or final checkpoint, no staging |
| +// lease taken and no status set since the counts before the pass, and the |
| +// claim still the moving op under the source with nothing sealed. |
| +func (h *cutHarness) nothingStopped(t *testing.T, reason string, acquired, statuses int) { |
| + t.Helper() |
| + m := h.move(t) |
| + if m.State != provision.MoveReady || m.CutPhase != "" || m.Inputs != nil || m.CutAt != nil || m.Reason != reason { |
| + t.Fatalf("not refused back to ready with %q: %+v", reason, m) |
| + } |
| + if h.ev.calls != 0 || len(h.holds) != 0 || h.fences != 0 || h.drains != 0 || h.checkpoints != 0 || len(h.locations) != 0 { |
| + t.Fatalf("something stopped: evidence %d, holds %v, fences %d, drains %d, checkpoints %d, leases read %v", |
| + h.ev.calls, h.holds, h.fences, h.drains, h.checkpoints, h.locations) |
| + } |
| + if got := h.lease.acquired; got != acquired { |
| + t.Fatalf("the refusal took the staging lease: %d acquisitions, want %d", got, acquired) |
| + } |
| + if len(h.statuses) != statuses { |
| + t.Fatalf("the source's row changed status: %v", h.statuses[statuses:]) |
| + } |
| + c := h.claim(t) |
| + if c.Op.Exec != "ie-box" || c.Op.Kind != hostclaim.KindMoving || c.Op.Phase != hostclaim.PhaseMoving { |
| + t.Fatalf("the claim moved: %+v", c.Op) |
| + } |
| + if _, err := journalledSeal(c); err == nil { |
| + t.Fatal("the claim carries a seal") |
| + } |
| +} |
| + |
| +// The harnesses arm both sides, so the cut runs exactly as before: the |
| +// target's report states T13, the source's recovery is armed. |
| +func TestCutGuardBothArmedCutsAsBefore(t *testing.T) { |
| + h := newCutHarness(t) |
| + h.ready(t) |
| + if r := h.targetReport(t); !r.Armed() || r.Checks[moves.CheckArmed] != (moves.Check{OK: true}) { |
| + t.Fatalf("the armed target's report: %+v", r.Checks[moves.CheckArmed]) |
| + } |
| + if !recoverySourceArmed(h.cfg) { |
| + t.Fatal("fixture: the source's recovery is not armed") |
| + } |
| + h.cut(t) |
| + h.pass(t) |
| + if m := h.move(t); m.State != provision.MoveLanding || m.CutPhase != provision.CutFlipped || m.Reason != "" { |
| + t.Fatalf("the armed cut did not flip: %+v", m) |
| + } |
| +} |
| + |
| +// A target whose report says it cannot land or roll back is refused at |
| +// requested, before the source's round takes the staging lease: the move |
| +// is ready again, unpinned, with the reason, and nothing stopped. So is a |
| +// report from an older box, which has no T13 at all. |
| +func TestCutGuardRefusesAnUnarmedTarget(t *testing.T) { |
| + ctx := context.Background() |
| + for name, spoil := range map[string]func(t *testing.T, h *cutHarness){ |
| + "its landing is not armed": func(t *testing.T, h *cutHarness) { |
| + h.targetPass(t, h.unarmedTarget(t, func(c *moveCfg) { c.OwnLease = nil })) |
| + }, |
| + "its rollback is not armed": func(t *testing.T, h *cutHarness) { |
| + h.targetPass(t, h.unarmedTarget(t, func(c *moveCfg) { c.DeleteHeld = nil })) |
| + }, |
| + "an older box's report has no T13": func(t *testing.T, h *cutHarness) { |
| + rec := h.read(t, p1Host) |
| + key := moves.TargetReportKey(moves.ReportPrefix(rec.Account, rec.App, rec.Host, rec.Move.ID)) |
| + body, _, err := h.s.Get(ctx, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var raw map[string]json.RawMessage |
| + if err := json.Unmarshal(body, &raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + var checks map[string]json.RawMessage |
| + if err := json.Unmarshal(raw["checks"], &checks); err != nil { |
| + t.Fatal(err) |
| + } |
| + delete(checks, moves.CheckArmed) |
| + raw["checks"], _ = json.Marshal(checks) |
| + body, _ = json.Marshal(raw) |
| + if err := moves.PutReplace(ctx, h.s, key, body); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + h := newCutHarness(t) |
| + h.ready(t) |
| + spoil(t, h) |
| + if h.targetReport(t).Armed() { |
| + t.Fatal("fixture: the report still states T13") |
| + } |
| + h.cut(t) |
| + acquired, statuses := h.lease.acquired, len(h.statuses) |
| + h.pass(t) |
| + h.nothingStopped(t, provision.RefusedTargetUnarmed, acquired, statuses) |
| + }) |
| + } |
| +} |
| + |
| +// A source that cannot recover a failed cut refuses it the same way. |
| +func TestCutGuardRefusesAnUnarmedSource(t *testing.T) { |
| + for name, unarm := range map[string]func(*moveCfg){ |
| + "no recovery open": func(c *moveCfg) { c.ReleaseHeld = nil }, |
| + "no hand-over": func(c *moveCfg) { c.HandHold = nil }, |
| + "no landing for return": func(c *moveCfg) { c.ParkRunHeld = nil }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + h := newCutHarness(t) |
| + h.ready(t) |
| + unarm(&h.cfg) |
| + if recoverySourceArmed(h.cfg) { |
| + t.Fatal("fixture: the source is still armed") |
| + } |
| + h.cut(t) |
| + acquired, statuses := h.lease.acquired, len(h.statuses) |
| + h.pass(t) |
| + h.nothingStopped(t, provision.RefusedSourceUnarmed, acquired, statuses) |
| + }) |
| + } |
| +} |
| + |
| +// At admitted (a record an older source admitted without the guard), the |
| +// guard refuses before the step to stopping. The target writes its report |
| +// while the move is ready, so the unarmed one is published before the cut. |
| +func TestCutGuardRefusesAtAdmitted(t *testing.T) { |
| + ctx := context.Background() |
| + h := newCutHarness(t) |
| + h.ready(t) |
| + h.targetPass(t, h.unarmedTarget(t, func(c *moveCfg) { c.SetNextDue = nil })) |
| + h.cut(t) |
| + g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc} |
| + now := h.clock.Now() |
| + if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120), 3)); err != nil { |
| + t.Fatal(err) |
| + } |
| + acquired, statuses := h.lease.acquired, len(h.statuses) |
| + h.pass(t) |
| + h.nothingStopped(t, provision.RefusedTargetUnarmed, acquired, statuses) |
| +} |
| + |
| +// The refusal stands without a loop: an automatic cut refused because the |
| +// source cannot recover is not pinned again or cut again while that holds, |
| +// so later passes write nothing. Once the source is armed, the next passing |
| +// round pins again and the automatic cut proceeds. |
| +func TestCutGuardRefusalStandsUntilArmed(t *testing.T) { |
| + h := newCutHarness(t) |
| + h.start(t, "auto") |
| + rootHolder(t, h.phase1Harness) |
| + armed := h.cfg |
| + h.cfg.ReleaseHeld = nil |
| + h.ev.stopped = false // stop at stopping once armed, to look at it |
| + h.pass(t) |
| + h.targetPass(t, h.target()) |
| + for i := 0; i < 3; i++ { |
| + h.pass(t) |
| + } |
| + refused := h.move(t) |
| + if refused.State != provision.MoveReady || refused.Inputs != nil || refused.Reason != provision.RefusedSourceUnarmed { |
| + t.Fatalf("the automatic cut was not refused: %+v", refused) |
| + } |
| + h.targetPass(t, h.target()) |
| + h.pass(t) |
| + h.pass(t) |
| + if m := h.move(t); m.UpdatedAt != refused.UpdatedAt || m.Inputs != nil || m.Reason != provision.RefusedSourceUnarmed { |
| + t.Fatalf("passes after the refusal wrote the record: %+v", m) |
| + } |
| + if h.ev.calls != 0 || len(h.holds) != 0 { |
| + t.Fatalf("something stopped: evidence %d, holds %v", h.ev.calls, h.holds) |
| + } |
| + h.cfg = armed |
| + h.pass(t) // pins again and requests the cut |
| + h.pass(t) // admits and starts stopping |
| + if m := h.move(t); m.State != provision.MoveCutting || m.CutPhase != provision.CutStopping || m.Reason != "" { |
| + t.Fatalf("the armed source did not cut: %+v", m) |
| + } |
| +} |
| + |
| +// With the switch off, a requested cut on an unarmed source is left as main |
| +// leaves it: nothing refuses, nothing is written. |
| +func TestCutGuardSwitchOffIsMain(t *testing.T) { |
| + h := newCutHarness(t) |
| + h.ready(t) |
| + h.cut(t) |
| + before := h.move(t) |
| + h.cfg.Enabled = false |
| + h.cfg.ReleaseHeld = nil |
| + h.pass(t) |
| + h.pass(t) |
| + if m := h.move(t); m.State != provision.MoveCutting || m.CutPhase != provision.CutRequested || m.Reason != "" || m.UpdatedAt != before.UpdatedAt { |
| + t.Fatalf("the switch-off pass wrote the record: %+v", m) |
| + } |
| + if h.ev.calls != 0 || len(h.holds) != 0 { |
| + t.Fatalf("something stopped: evidence %d, holds %v", h.ev.calls, h.holds) |
| + } |
| +} |
| + |
| +// T13 gates readiness (§7.3): an unarmed target's report fails T13 with its |
| +// sentence, and the move stays preparing, so `carlos move status` shows why |
| +// before anyone asks to cut. Armed, it passes and the move is ready. |
| +func TestTargetReportStatesItsArming(t *testing.T) { |
| + h := newCutHarness(t) |
| + h.start(t, "manual") |
| + rootHolder(t, h.phase1Harness) |
| + h.pass(t) |
| + h.targetPass(t, h.unarmedTarget(t, func(c *moveCfg) { c.ParkView = nil })) |
| + r := h.targetReport(t) |
| + if f := failing(r); len(f) != 1 || f[moves.CheckArmed] != t13NotArmed { |
| + t.Fatalf("an unarmed target's failing checks: %v", f) |
| + } |
| + h.pass(t) |
| + if m := h.move(t); m.State != provision.MovePreparing { |
| + t.Fatalf("an unarmed target's move: %+v", m) |
| + } |
| + h.targetPass(t, h.target()) |
| + if f := failing(h.targetReport(t)); len(f) != 0 { |
| + t.Fatalf("an armed target's failing checks: %v", f) |
| + } |
| + h.pass(t) |
| + if m := h.move(t); m.State != provision.MoveReady || m.Inputs == nil { |
| + t.Fatalf("an armed target's move: %+v", m) |
| + } |
| +} |
diff --git a/internal/host/movesphase1.go b/internal/host/movesphase1.go| index 952c3a3c1..32d6ad17f 100644 |
| --- a/internal/host/movesphase1.go |
| +++ b/internal/host/movesphase1.go |
| @@ -885,6 +885,16 @@ func (w *movesWorker) decideReady(ctx context.Context, s release.Store, rec prov |
| return nil |
| } |
| } |
| + // The cut guard (Paul's ruling, 2026-10-11): a cut this box refused |
| + // because it cannot recover the move (cutRun.refuseUnarmed) is not |
| + // pinned again while that holds, so an automatic cut does not loop |
| + // through the refusal. The first cut is still requested, and its |
| + // refusal records the reason the operator reads. A target that cannot |
| + // land fails T13, so Ready below keeps it from pinning. |
| + if repin && !recoverySourceArmed(cfg) { |
| + w.notice(rec.Host+"#unarmed", "moves: %s: move %s is not pinned again: %s", rec.Host, g.ID, provision.RefusedSourceUnarmed) |
| + return nil |
| + } |
| if state == provision.MovePreparing || repin { |
| target, err := readTargetReport(ctx, s, rec) |
| if err != nil { |
diff --git a/internal/host/movesphase1_harness_test.go b/internal/host/movesphase1_harness_test.go| index 0aea92faf..75eb0a29c 100644 |
| --- a/internal/host/movesphase1_harness_test.go |
| +++ b/internal/host/movesphase1_harness_test.go |
| @@ -533,6 +533,9 @@ func (h *phase1Harness) targetCfg() moveCfg { |
| now := h.clock.Now() |
| return moves.MeasuredRate{MiBPerS: 500, At: now, Bytes: 128 << 20}, moves.FilesRate{PerS: 1600, At: now, Files: 512}, nil |
| } |
| + // The cut guard's T13: the target states it can land and roll back, |
| + // as every box will once claim Plan 12 lands (armStandIns). |
| + armStandIns(&c, h.s, roleTarget) |
| return c |
| } |
diff --git a/internal/host/movesrecover_test.go b/internal/host/movesrecover_test.go| index b00ea422e..416c0ad40 100644 |
| --- a/internal/host/movesrecover_test.go |
| +++ b/internal/host/movesrecover_test.go |
| @@ -298,8 +298,13 @@ func TestSourceWaitsForTargetEvidence(t *testing.T) { |
| t.Run("unarmed in production", func(t *testing.T) { |
| h := newRecoverHarness(t, false) |
| h.released(t) |
| - // h.cfg is the source box as production wires it: no park |
| - // engine leases (ruling G17), so the recovery waits for Plan 5. |
| + // The source box as production wires it: no park engine leases |
| + // (ruling G17), so the recovery waits for Plan 5. h.cfg was armed |
| + // for the cut guard (armStandIns), so the leases are taken away. |
| + h.cfg.ParkLeases = nil |
| + if recoverySourceArmed(h.cfg) { |
| + t.Fatal("fixture: the source's recovery is still armed") |
| + } |
| h.pass(t) |
| untouched(t, h) |
| }) |
diff --git a/internal/host/movesreport.go b/internal/host/movesreport.go| index b1ffb51a3..b38e8a202 100644 |
| --- a/internal/host/movesreport.go |
| +++ b/internal/host/movesreport.go |
| @@ -24,7 +24,8 @@ import ( |
| "github.com/superfly/ltx" |
| ) |
| -// The target's readiness report (moves spec §7.3, T1 to T12), behind |
| +// The target's readiness report (moves spec §7.3, T1 to T12, and the cut |
| +// guard's T13), behind |
| // CARLOS_MOVES_PHASE1. The named target box (the claim's op.intent.target) |
| // writes it every pass for every move into its placement that is preparing |
| // or ready; no other box writes one. Each check is its own small function |
| @@ -56,6 +57,7 @@ const ( |
| t10Failed = "this box cannot write the return lane in the source's bucket" |
| t11NoReplica = "no replica has been staged yet" |
| t11Unmeasured = "the database size could not be measured" // MV-N69 |
| + t13NotArmed = "this box cannot land the move or roll it back yet" |
| ) |
| func t1Below(min int) string { |
| @@ -244,6 +246,7 @@ func (w *movesWorker) targetReport(ctx context.Context, s release.Store, rec pro |
| rep.Checks[moves.CheckClaim] = w.checkClaim(ctx, obs, rec) |
| rep.Checks[moves.CheckLane] = w.checkLane(ctx, rec, fleet) |
| rep.Checks[moves.CheckQuiet] = checkQuiet(obs, rec.Move.To, now) |
| + rep.Checks[moves.CheckArmed] = checkArmed(cfg) |
| // T11 from measured rates: this box's own, and the source's cross rate. |
| m := w.measure(ctx, rec) |
| @@ -274,6 +277,19 @@ func (w *movesWorker) targetReport(ctx context.Context, s release.Store, rec pro |
| return moves.PutReplace(ctx, s, moves.TargetReportKey(moves.ReportPrefix(rec.Account, rec.App, rec.Host, id)), body) |
| } |
| +// checkArmed is T13, the cut guard's arming statement (Paul's ruling, |
| +// 2026-10-11): this box can land the move and roll it back, every hook of |
| +// both wired, judged as the report is written. The source refuses to start |
| +// a cut unless the target's latest report states it (cutRun.refuseUnarmed). |
| +// In production both stay unarmed until claim Plan 12 lands. Its reason is |
| +// copy record MV-N440. |
| +func checkArmed(cfg moveCfg) moves.Check { |
| + if landingArmed(cfg) && rollbackTargetArmed(cfg) { |
| + return moves.Check{OK: true} |
| + } |
| + return moves.Check{Reason: t13NotArmed} |
| +} |
| + |
| // checkProtocol is T1 (§11): this box speaks at least the published floor |
| // and has armed it locally, so its executable selection enforces it. No |
| // published floor is a pass: the floor is opt-in until the first |
diff --git a/internal/moves/readiness.go b/internal/moves/readiness.go| index 937f49c4b..a425e9e62 100644 |
| --- a/internal/moves/readiness.go |
| +++ b/internal/moves/readiness.go |
| @@ -12,7 +12,8 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -// The readiness checks of §7.3. The target box writes T1 to T12 into its |
| +// The readiness checks of §7.3. The target box writes T1 to T13 (T13 the cut |
| +// guard's, Paul's ruling of 2026-10-11) into its |
| // report; the source box writes S1 and S2 into its own. Ready joins them. |
| const ( |
| CheckProtocol = "T1" |
| @@ -27,11 +28,17 @@ const ( |
| CheckLane = "T10" |
| CheckBudget = "T11" |
| CheckQuiet = "T12" |
| + // CheckArmed is the cut guard's T13 (Paul's ruling, 2026-10-11): the |
| + // target box can land the move and roll it back, every hook of both |
| + // wired, judged when the report is written. It is the report's arming |
| + // statement: the source refuses to start a cut unless the target's |
| + // latest report states it passing (TargetReport.Armed). |
| + CheckArmed = "T13" |
| ) |
| // AllChecks lists the target's checks in id order. Passing walks it, so the |
| // first failure it names is the lowest-numbered one. |
| -var AllChecks = []string{CheckProtocol, CheckSecrets, CheckEnv, CheckStorage, CheckArchive, CheckDisk, CheckRelease, CheckOneOwner, CheckClaim, CheckLane, CheckBudget, CheckQuiet} |
| +var AllChecks = []string{CheckProtocol, CheckSecrets, CheckEnv, CheckStorage, CheckArchive, CheckDisk, CheckRelease, CheckOneOwner, CheckClaim, CheckLane, CheckBudget, CheckQuiet, CheckArmed} |
| // ReportFreshness bounds how old a report may be and still decide ready |
| // (§7.3); the cut re-checks it at `admitted` (§7.4). |
| @@ -155,6 +162,17 @@ func (s *SourceReport) CrossMeasurement() MeasuredRate { |
| return MeasuredRate{MiBPerS: s.CrossRate, At: s.CrossRateAt} |
| } |
| +// Armed reports whether the report states that its box can land the move |
| +// and roll it back (CheckArmed). It fails closed: no report, a report from |
| +// an older box that has no T13, and a failing T13 are all unarmed. |
| +func (r *TargetReport) Armed() bool { |
| + if r == nil { |
| + return false |
| + } |
| + c, ok := r.Checks[CheckArmed] |
| + return ok && c.OK |
| +} |
| + |
| // Passing reports whether every check passed, naming the first that did not |
| // in id order. A check that is missing from the report fails. |
| func (r TargetReport) Passing() (bool, string) { |
diff --git a/internal/moves/readiness_test.go b/internal/moves/readiness_test.go| index 24edb851a..0db6d5b24 100644 |
| --- a/internal/moves/readiness_test.go |
| +++ b/internal/moves/readiness_test.go |
| @@ -323,12 +323,12 @@ func TestPassingNamesTheFirstFailureInIDOrder(t *testing.T) { |
| } |
| } |
| -func TestAllChecksListsT1ToT12(t *testing.T) { |
| - want := []string{"T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8", "T9", "T10", "T11", "T12"} |
| +func TestAllChecksListsT1ToT13(t *testing.T) { |
| + want := []string{"T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8", "T9", "T10", "T11", "T12", "T13"} |
| if !reflect.DeepEqual(AllChecks, want) { |
| t.Fatalf("%v", AllChecks) |
| } |
| - got := []string{CheckProtocol, CheckSecrets, CheckEnv, CheckStorage, CheckArchive, CheckDisk, CheckRelease, CheckOneOwner, CheckClaim, CheckLane, CheckBudget, CheckQuiet} |
| + got := []string{CheckProtocol, CheckSecrets, CheckEnv, CheckStorage, CheckArchive, CheckDisk, CheckRelease, CheckOneOwner, CheckClaim, CheckLane, CheckBudget, CheckQuiet, CheckArmed} |
| if !reflect.DeepEqual(got, want) { |
| t.Fatalf("%v", got) |
| } |
| @@ -905,3 +905,40 @@ func TestReadyRefusesADatabaseGrownSinceTheTargetPriced(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// The cut guard (Paul's ruling, 2026-10-11): T13 is the target's statement |
| +// that it can land the move and roll it back. Only a report that states it |
| +// passing is armed: a nil report, an older box's report that has no T13, |
| +// and a failing T13 all read unarmed, and an older report is not ready. |
| +func TestTargetReportArmedFailsClosed(t *testing.T) { |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + var none *TargetReport |
| + if none.Armed() { |
| + t.Fatal("a missing report is armed") |
| + } |
| + r := passingTarget(now) |
| + if !r.Armed() { |
| + t.Fatal("a report with a passing T13 is not armed") |
| + } |
| + r.Checks[CheckArmed] = Check{Reason: "not armed"} |
| + if r.Armed() { |
| + t.Fatal("a failing T13 is armed") |
| + } |
| + old := passingTarget(now) |
| + delete(old.Checks, CheckArmed) |
| + if old.Armed() { |
| + t.Fatal("an older box's report, with no T13, is armed") |
| + } |
| + if ok, why := Ready(0, 16<<20, old, goodSource(now), now); ok || why != "T13 was not reported" { |
| + t.Fatalf("an older box's report: ready %v (%q)", ok, why) |
| + } |
| + // The wire form: an older box's JSON has no T13 and reads unarmed. |
| + body, err := json.Marshal(old) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var back TargetReport |
| + if err := json.Unmarshal(body, &back); err != nil || back.Armed() { |
| + t.Fatalf("round trip of an older report: armed %v, %v", back.Armed(), err) |
| + } |
| +} |
diff --git a/internal/provision/move_test.go b/internal/provision/move_test.go| index 4f0ddd1ff..a118a429f 100644 |
| --- a/internal/provision/move_test.go |
| +++ b/internal/provision/move_test.go |
| @@ -584,6 +584,25 @@ func TestBackToReadyRecordsARefusal(t *testing.T) { |
| } |
| } |
| +// The cut guard (Paul's ruling, 2026-10-11): an unarmed target or an |
| +// unarmed source sends the cut back to ready from requested or admitted, |
| +// and each records its own sentence, not the replica layout's. |
| +func TestBackToReadyRecordsTheCutGuardRefusals(t *testing.T) { |
| + g := cutTestGuard() |
| + now := time.Now() |
| + for _, reason := range []string{RefusedTargetUnarmed, RefusedSourceUnarmed} { |
| + for _, phase := range []string{CutRequested, CutAdmitted} { |
| + rec := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveCutting, CutPhase: phase, CutAt: &now, CutBudgetS: 120, CutDeadline: &now, Inputs: &MoveInputs{Release: "v1"}}} |
| + if err := MoveBackToReadyRefused(g, reason)(rec); err != nil || rec.Move.State != MoveReady || rec.Move.CutPhase != "" || rec.Move.Inputs != nil || rec.Move.Reason != reason { |
| + t.Fatalf("%s at %s: %v %+v", reason, phase, err, rec.Move) |
| + } |
| + } |
| + } |
| + if RefusedTargetUnarmed == RefusedSourceUnarmed || RefusedTargetUnarmed == RefusedReplicaLayout || RefusedSourceUnarmed == RefusedReplicaLayout { |
| + t.Fatal("the refusals do not each have their own sentence") |
| + } |
| +} |
| + |
| // The landing marks a rollback it cannot yet start (Plan 2 Task 10): the |
| // reason is written on the landing record, the state and phase stay as they |
| // are, and Plan 4's rollback transition is the one call site that replaces |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index bd948a634..1e41b5a05 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -1478,6 +1478,17 @@ func MoveAdmit(g MoveGuard, now time.Time, budgetS int, deadline time.Time, atte |
| // recorded sentence (copy record MV-N167), like the Revert reasons. |
| const RefusedReplicaLayout = "the instance keeps its data under a per-incarnation path, which moves cannot cut yet" |
| +// The cut guard's refusals (Paul's ruling, 2026-10-11), recorded by |
| +// MoveBackToReadyRefused before anything stops. RefusedTargetUnarmed: the |
| +// target's latest report does not state that its box can land the move and |
| +// roll it back (moves.TargetReport.Armed; a report from an older box never |
| +// does). RefusedSourceUnarmed: the source box cannot run the recovery a |
| +// failed cut would need. Recorded sentences (copy record MV-N441, MV-N442). |
| +const ( |
| + RefusedTargetUnarmed = "the target box cannot land the move or roll it back yet" |
| + RefusedSourceUnarmed = "the source box cannot recover the move if the cut fails" |
| +) |
| + |
| // MoveBackToReadyRefused is MoveBackToReady with the reason recorded on the |
| // move, for a refusal that stands until something outside the move |
| // changes (the operator reads it in `carlos move status`). reason must be |
| @@ -1485,14 +1496,23 @@ const RefusedReplicaLayout = "the instance keeps its data under a per-incarnatio |
| func MoveBackToReadyRefused(g MoveGuard, reason string) func(*Record) error { |
| return func(rec *Record) error { |
| switch reason { |
| - case RefusedReplicaLayout: |
| + case RefusedReplicaLayout, RefusedTargetUnarmed, RefusedSourceUnarmed: |
| default: |
| return fmt.Errorf("provision: refusal reason %q has no recorded sentence", reason) |
| } |
| if err := MoveBackToReady(g)(rec); err != nil { |
| return err |
| } |
| - rec.Move.Reason = RefusedReplicaLayout |
| + // Each reason is written as its constant, so the copy gate reads |
| + // the recorded sentence (as MoveRevert writes its reasons). |
| + switch reason { |
| + case RefusedTargetUnarmed: |
| + rec.Move.Reason = RefusedTargetUnarmed |
| + case RefusedSourceUnarmed: |
| + rec.Move.Reason = RefusedSourceUnarmed |
| + default: |
| + rec.Move.Reason = RefusedReplicaLayout |
| + } |
| return nil |
| } |
| } |