provision: commit conditional on the deadlines, open, source park, close (§7.9)
MoveCommit takes landing/probed to committed, refused past the earlier of cut_deadline and landing_deadline and over a recorded rollback reason (ruling H8). MoveOpened and MoveSourceParked stamp a committed move, MoveClose clears it into LastMove (D11), MoveProbeAttempt counts attempts on a probing landing. Two new rollback reasons (probe out of attempts, commit past the deadline) with copy rows MV-N182 and MV-N183, pending.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
4 files changed,
+319
−1
approved_moves_copy_test.go+1 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+6 −1internal/provision/move_test.go+177 −0internal/provision/provision.go+135 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 197d9427c..ef0477bd0 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -70,6 +70,7 @@ var movesCopyIDs = []string{ |
| "MV-N163", "MV-N164", "MV-N165", "MV-N166", "MV-N167", "MV-N168", |
| "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", |
| } |
| // 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 57c2347bf..40607c9e1 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -231,7 +231,12 @@ Columns: id, file, text, status. |
| ## Plan 3 |
| -(appended by Plan 3 Task 9) |
| +(Task 2's rows, then the rest appended by Plan 3 Task 9) |
| + |
| +| id | file | text | status | |
| +|---|---|---|---| |
| +| MV-N182 | internal/provision/provision.go | the probe ran out of attempts | pending | |
| +| MV-N183 | internal/provision/provision.go | the commit came after the deadline | pending | |
| ## Plan 4 |
diff --git a/internal/provision/move_test.go b/internal/provision/move_test.go| index 529935a77..5030ab441 100644 |
| --- a/internal/provision/move_test.go |
| +++ b/internal/provision/move_test.go |
| @@ -623,3 +623,180 @@ func TestMarkRollbackRecordsASentenceOnALandingRecord(t *testing.T) { |
| t.Fatal("LandingOrder hands out the package's slice") |
| } |
| } |
| + |
| +func commitTestRecord(g MoveGuard, dl time.Time) *Record { |
| + return &Record{Region: "uk", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveLanding, LandingPhase: LandProbed, CutDeadline: &dl}} |
| +} |
| + |
| +func TestCommitRefusedPastTheDeadline(t *testing.T) { |
| + g := MoveGuard{ID: "mv_abcdefghijklmnopqrstuvwxyz", Incarnation: strings.Repeat("ab", 32)} |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + dl := now.Add(115 * time.Second) |
| + cut := now.Add(-10 * time.Second) |
| + rec := &Record{Region: "uk", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveLanding, LandingPhase: LandProbed, CutDeadline: &dl, CutAt: &cut}} |
| + if err := MoveCommit(g, dl.Add(time.Second))(rec); !errors.Is(err, ErrCutDeadline) || !errors.Is(err, ErrMoveConflict) || rec.Move.State != MoveLanding { |
| + t.Fatalf("late commit: %v %s", err, rec.Move.State) |
| + } |
| + if err := MoveCommit(g, dl)(rec); err != nil || rec.Move.State != MoveCommitted || rec.Move.CommittedAt == nil { |
| + t.Fatalf("commit at the deadline: %v", err) |
| + } |
| + if err := MoveCommit(g, dl)(rec); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatal("second commit") |
| + } |
| + if err := MoveClose(g, now)(rec); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatal("close before open and park") |
| + } |
| + if err := MoveOpened(g, now)(rec); err != nil || rec.Move.OpenedAt == nil { |
| + t.Fatal(err) |
| + } |
| + if err := MoveClose(g, now)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move == nil { |
| + t.Fatalf("close before the source park: %v", err) |
| + } |
| + if err := MoveSourceParked(g, now)(rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := MoveClose(g, now)(rec); err != nil || rec.Move != nil || rec.Region != "uk" || rec.LastMove == nil || rec.LastMove.ID != g.ID || rec.LastMove.Outcome != "done" { |
| + t.Fatalf("close: %v %+v", err, rec) |
| + } |
| + if rec.LastMove.OfflineS != 10 || !rec.LastMove.At.Equal(now) { |
| + t.Fatalf("last move: %+v", rec.LastMove) |
| + } |
| +} |
| + |
| +// Ruling H8: a recorded rollback wins over a commit, and the earlier of the |
| +// cut and landing deadlines bounds it. |
| +func TestCommitRefusesARecordedRollbackAndTheEarlierDeadline(t *testing.T) { |
| + g := cutTestGuard() |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + cutDL, landDL := now.Add(115*time.Second), now.Add(60*time.Second) |
| + rec := commitTestRecord(g, cutDL) |
| + rec.Move.LandingDeadline = &landDL |
| + if err := MoveCommit(g, landDL.Add(time.Second))(rec); !errors.Is(err, ErrCutDeadline) || rec.Move.State != MoveLanding { |
| + t.Fatalf("past the landing deadline, inside the cut's: %v", err) |
| + } |
| + if err := MoveCommit(g, landDL)(rec); err != nil || rec.Move.State != MoveCommitted { |
| + t.Fatalf("at the earlier deadline: %v", err) |
| + } |
| + // The landing deadline alone does not stand in for the cut's. |
| + rec = commitTestRecord(g, landDL) |
| + rec.Move.LandingDeadline = &cutDL |
| + if err := MoveCommit(g, landDL.Add(time.Second))(rec); !errors.Is(err, ErrCutDeadline) { |
| + t.Fatalf("past the cut deadline, inside the landing's: %v", err) |
| + } |
| + // A recorded rollback reason refuses the commit; nothing is written. |
| + rec = commitTestRecord(g, cutDL) |
| + if err := MoveMarkRollback(g, RollbackLandingDeadline)(rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := MoveCommit(g, now)(rec); !errors.Is(err, ErrMoveConflict) || errors.Is(err, ErrCutDeadline) || rec.Move.State != MoveLanding || rec.Move.CommittedAt != nil { |
| + t.Fatalf("commit over a rollback marker: %v %+v", err, rec.Move) |
| + } |
| + // No cut deadline recorded: the commit cannot be shown to be in time. |
| + rec = commitTestRecord(g, cutDL) |
| + rec.Move.CutDeadline = nil |
| + if err := MoveCommit(g, now)(rec); !errors.Is(err, ErrCutDeadline) { |
| + t.Fatalf("no cut deadline: %v", err) |
| + } |
| + // Not probed yet, a replacement move, and a record with no move. |
| + for _, phase := range []string{LandChecking, LandRestored, LandProbing} { |
| + rec = commitTestRecord(g, cutDL) |
| + rec.Move.LandingPhase = phase |
| + if err := MoveCommit(g, now)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.State != MoveLanding { |
| + t.Fatalf("commit from %s: %v", phase, err) |
| + } |
| + } |
| + other := MoveGuard{ID: g.ID, Incarnation: strings.Repeat("cd", 32)} |
| + if err := MoveCommit(other, now)(commitTestRecord(g, cutDL)); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatalf("another incarnation: %v", err) |
| + } |
| + if err := MoveCommit(g, now)(&Record{}); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatalf("no move: %v", err) |
| + } |
| +} |
| + |
| +func TestOpenedParkedAndCloseAreCommittedOnly(t *testing.T) { |
| + g := cutTestGuard() |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + later := now.Add(time.Minute) |
| + for _, state := range []string{MoveLanding, MoveCutting, MoveReverting, MoveRollingBack} { |
| + for name, step := range map[string]func(*Record) error{"opened": MoveOpened(g, now), "parked": MoveSourceParked(g, now), "close": MoveClose(g, now)} { |
| + rec := commitTestRecord(g, now) |
| + rec.Move.State = state |
| + if err := step(rec); !errors.Is(err, ErrMoveConflict) || rec.Move == nil || rec.Move.OpenedAt != nil || rec.Move.SourceParkedAt != nil { |
| + t.Fatalf("%s from %s: %v", name, state, err) |
| + } |
| + } |
| + } |
| + // A replay keeps the first stamp. |
| + rec := commitTestRecord(g, now) |
| + rec.Move.State = MoveCommitted |
| + if err := MoveOpened(g, now)(rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := MoveOpened(g, later)(rec); err != nil || !rec.Move.OpenedAt.Equal(now) { |
| + t.Fatalf("opened replay: %v %v", err, rec.Move.OpenedAt) |
| + } |
| + if err := MoveSourceParked(g, now)(rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := MoveSourceParked(g, later)(rec); err != nil || !rec.Move.SourceParkedAt.Equal(now) { |
| + t.Fatalf("parked replay: %v %v", err, rec.Move.SourceParkedAt) |
| + } |
| + // Close with no cut_at recorded (a record from before it was stamped): |
| + // the outcome is written and the offline time is left at zero. |
| + if err := MoveClose(g, later)(rec); err != nil || rec.Move != nil || rec.LastMove == nil || rec.LastMove.OfflineS != 0 { |
| + t.Fatalf("close without cut_at: %v %+v", err, rec.LastMove) |
| + } |
| + // Close refuses a record whose region is not the target's. |
| + rec = commitTestRecord(g, now) |
| + rec.Move.State, rec.Move.OpenedAt, rec.Move.SourceParkedAt = MoveCommitted, &now, &now |
| + rec.Region = "ie" |
| + if err := MoveClose(g, later)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move == nil || rec.LastMove != nil { |
| + t.Fatalf("close with the region still the source's: %v", err) |
| + } |
| +} |
| + |
| +func TestProbeAttemptRecordsTheCountOnAProbingLanding(t *testing.T) { |
| + g := cutTestGuard() |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + rec := commitTestRecord(g, now) |
| + rec.Move.LandingPhase = LandProbing |
| + for _, n := range []int{1, 1, 2, 3} { |
| + if err := MoveProbeAttempt(g, n)(rec); err != nil || rec.Move.Landing == nil || rec.Move.Landing.Attempts != n { |
| + t.Fatalf("attempt %d: %v %+v", n, err, rec.Move.Landing) |
| + } |
| + } |
| + rec.Move.Landing.InstalledDBSHA256 = "keep" |
| + if err := MoveProbeAttempt(g, 4)(rec); err != nil || rec.Move.Landing.InstalledDBSHA256 != "keep" { |
| + t.Fatalf("the install digest was lost: %v %+v", err, rec.Move.Landing) |
| + } |
| + for _, n := range []int{3, 0, -1} { |
| + if err := MoveProbeAttempt(g, n)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Landing.Attempts != 4 { |
| + t.Fatalf("attempt %d after 4: %v %+v", n, err, rec.Move.Landing) |
| + } |
| + } |
| + for _, phase := range []string{LandChecking, LandRestored, LandProbed} { |
| + rec := commitTestRecord(g, now) |
| + rec.Move.LandingPhase = phase |
| + if err := MoveProbeAttempt(g, 1)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Landing != nil { |
| + t.Fatalf("from %s: %v", phase, err) |
| + } |
| + } |
| + rec = commitTestRecord(g, now) |
| + rec.Move.LandingPhase = LandProbing |
| + if err := MoveMarkRollback(g, RollbackProbeAttempts)(rec); err != nil || rec.Move.Reason != RollbackProbeAttempts { |
| + t.Fatalf("out of attempts: %v %q", err, rec.Move.Reason) |
| + } |
| + if err := MoveProbeAttempt(g, 1)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Landing != nil { |
| + t.Fatalf("an attempt over a rollback marker: %v", err) |
| + } |
| + rec = commitTestRecord(g, now) |
| + if err := MoveMarkRollback(g, RollbackCommitDeadline)(rec); err != nil || rec.Move.Reason != RollbackCommitDeadline { |
| + t.Fatalf("commit past the deadline: %v %q", err, rec.Move.Reason) |
| + } |
| + rec = commitTestRecord(g, now) |
| + rec.Move.State = MoveCommitted |
| + if err := MoveProbeAttempt(g, 1)(rec); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatalf("committed: %v", err) |
| + } |
| +} |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 1dba6ebb3..71345a85a 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -1566,6 +1566,12 @@ const ( |
| // installation registered them, so nothing retired them). They are not |
| // the landing's to clear (final review, finding 1). |
| RollbackFilesOccupied = "the files directory already holds files this move did not write" |
| + // RollbackProbeAttempts: the probe used every attempt it was given |
| + // without the app answering (§7.7). |
| + RollbackProbeAttempts = "the probe ran out of attempts" |
| + // RollbackCommitDeadline: the probe passed, but the commit would have |
| + // come after the cut or landing deadline (§7.9). |
| + RollbackCommitDeadline = "the commit came after the deadline" |
| ) |
| // MoveMarkRollback records on a landing record why the landing stopped |
| @@ -1600,6 +1606,10 @@ func MoveMarkRollback(g MoveGuard, reason string) func(*Record) error { |
| rec.Move.Reason = RollbackInstallMismatch |
| case RollbackFilesOccupied: |
| rec.Move.Reason = RollbackFilesOccupied |
| + case RollbackProbeAttempts: |
| + rec.Move.Reason = RollbackProbeAttempts |
| + case RollbackCommitDeadline: |
| + rec.Move.Reason = RollbackCommitDeadline |
| default: |
| return fmt.Errorf("provision: rollback reason %q has no recorded sentence", reason) |
| } |
| @@ -1607,6 +1617,131 @@ func MoveMarkRollback(g MoveGuard, reason string) func(*Record) error { |
| } |
| } |
| +// MoveProbeAttempt records, on a landing in its probing phase, how many |
| +// probe attempts have begun (§7.7). The count only goes up: the same n again |
| +// is a replay and changes nothing, a smaller one is a late write from an |
| +// earlier attempt and is refused. It is refused too once a rollback reason |
| +// is recorded, because the probe is then over. A record at probing says |
| +// only that the probe is running and how many attempts it has made; whether |
| +// the attempt's first start has been journalled is the claim's to say |
| +// (hostclaim.WakeRetryAttempt), not the record's. |
| +func MoveProbeAttempt(g MoveGuard, n int) func(*Record) error { |
| + return func(rec *Record) error { |
| + if err := g.check(rec); err != nil { |
| + return err |
| + } |
| + m := rec.Move |
| + if m.State != MoveLanding || m.LandingPhase != LandProbing || m.Reason != "" { |
| + return fmt.Errorf("%w: probe attempts are counted while a landing is probing", ErrMoveConflict) |
| + } |
| + have := 0 |
| + if m.Landing != nil { |
| + have = m.Landing.Attempts |
| + } |
| + if n < 1 || n < have { |
| + return fmt.Errorf("%w: the attempt count does not go down", ErrMoveConflict) |
| + } |
| + if m.Landing == nil { |
| + m.Landing = &MoveLandingJournal{} |
| + } |
| + m.Landing.Attempts = n |
| + return nil |
| + } |
| +} |
| + |
| +// ErrCutDeadline is a commit attempted after the deadline that bounds it |
| +// (§7.4, §7.9): the record CAS is the decision, so the deadline cannot be |
| +// passed between the record write and the claim write. |
| +var ErrCutDeadline = fmt.Errorf("%w: the commit deadline has passed", ErrMoveConflict) |
| + |
| +// MoveCommit is the commit (§7.9): landing/probed to committed, in one |
| +// record write. It is refused when a rollback reason is already recorded, so |
| +// a recorded rollback wins over a commit that raced it, and when now is |
| +// after the earlier of cut_deadline and landing_deadline (ruling H8: §7.9 |
| +// names the first and §7.8 makes the second a rollback trigger, so the |
| +// earlier bounds the commit). A record with no cut_deadline cannot be shown |
| +// to be in time and is refused. now equal to the deadline is in time. |
| +// |
| +// A replay finds the move already committed and is an ErrMoveConflict; the |
| +// caller re-reads the record and accepts committed for its own move (H22). |
| +func MoveCommit(g MoveGuard, now time.Time) func(*Record) error { |
| + return func(rec *Record) error { |
| + if err := g.check(rec); err != nil { |
| + return err |
| + } |
| + m := rec.Move |
| + if m.State != MoveLanding || m.LandingPhase != LandProbed { |
| + return fmt.Errorf("%w: commit follows probed", ErrMoveConflict) |
| + } |
| + if m.Reason != "" { |
| + return fmt.Errorf("%w: a rollback is recorded for this landing", ErrMoveConflict) |
| + } |
| + if m.CutDeadline == nil || now.After(*m.CutDeadline) || (m.LandingDeadline != nil && now.After(*m.LandingDeadline)) { |
| + return ErrCutDeadline |
| + } |
| + t := now.UTC() |
| + m.State, m.CommittedAt = MoveCommitted, &t |
| + return nil |
| + } |
| +} |
| + |
| +// committedStamp stamps one of a committed move's obligations. The first |
| +// stamp stands: a replay changes nothing. |
| +func committedStamp(g MoveGuard, now time.Time, field func(*MoveState) **time.Time) func(*Record) error { |
| + return func(rec *Record) error { |
| + if err := g.check(rec); err != nil { |
| + return err |
| + } |
| + if rec.Move.State != MoveCommitted { |
| + return fmt.Errorf("%w: this follows the commit", ErrMoveConflict) |
| + } |
| + if p := field(rec.Move); *p == nil { |
| + t := now.UTC() |
| + *p = &t |
| + } |
| + return nil |
| + } |
| +} |
| + |
| +// MoveOpened stamps opened_at on a committed move: the target is serving |
| +// (§7.9). |
| +func MoveOpened(g MoveGuard, now time.Time) func(*Record) error { |
| + return committedStamp(g, now, func(m *MoveState) **time.Time { return &m.OpenedAt }) |
| +} |
| + |
| +// MoveSourceParked stamps source_parked_at on a committed move: the source |
| +// has parked what it kept (§7.9). |
| +func MoveSourceParked(g MoveGuard, now time.Time) func(*Record) error { |
| + return committedStamp(g, now, func(m *MoveState) **time.Time { return &m.SourceParkedAt }) |
| +} |
| + |
| +// MoveClose is the close (§7.9 step 5, index D11): a committed move with |
| +// both obligations stamped clears Move and leaves the outcome in LastMove, |
| +// in the same write. The region is already the target's from the flip; a |
| +// record whose region is not is refused rather than closed on a lie. The |
| +// offline time is opened_at minus cut_at, zero when cut_at was never |
| +// stamped. Plan 5 calls it once no obligation under the move remains. |
| +func MoveClose(g MoveGuard, now time.Time) func(*Record) error { |
| + return func(rec *Record) error { |
| + if err := g.check(rec); err != nil { |
| + return err |
| + } |
| + m := rec.Move |
| + if m.State != MoveCommitted || m.OpenedAt == nil || m.SourceParkedAt == nil { |
| + return fmt.Errorf("%w: close follows the open and the source park", ErrMoveConflict) |
| + } |
| + if rec.Region != m.To { |
| + return fmt.Errorf("%w: the record's region is not the target's", ErrMoveConflict) |
| + } |
| + out := &MoveOutcome{ID: g.ID, Outcome: "done", At: now.UTC()} |
| + if m.CutAt != nil { |
| + out.OfflineS = m.OpenedAt.Sub(*m.CutAt).Seconds() |
| + } |
| + rec.LastMove, rec.Move = out, nil |
| + return nil |
| + } |
| +} |
| + |
| // InstanceStoreEnv is the environment name an instance's private object |
| // store hangs off: the record's own Environment, or its Slug when it has |
| // none. Twin of the create door's inline rule (internal/console/ |