carlos / platform Public

provision: cut, flip and landing transitions under the move guard (§7.4 to §7.6)

MoveCutPhase, MoveAdmit (no inputs: the pin from readiness is required),
MoveBackToReady (clears the pin), MoveGuardedRepin, MoveRecordSource,
MoveFlip (refuses any state but cutting/archived), MoveLandingPhase and
MoveRevert (recorded reasons only). MoveGuardedCut refuses a move with no
pinned inputs.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev cec8b3394455d7d21fdf5dbe63a79bec5aac2c9b parent 5aa9a94
2 files changed, +483 −8
  • internal/provision/move_test.go +275 −0
  • internal/provision/provision.go +208 −8
diff --git a/internal/provision/move_test.go b/internal/provision/move_test.go
index b8b3e5419..9610093ce 100644
--- a/internal/provision/move_test.go
+++ b/internal/provision/move_test.go
@@ -166,6 +166,11 @@ func TestGuardedCutAndAbortComplete(t *testing.T) {
t.Fatalf("cut from preparing: %v", err)
}
rec.Move.State = MoveReady
+ // G1: a ready move with no pinned inputs cannot be cut.
+ if err := MoveGuardedCut(g)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.State != MoveReady || rec.Move.CutPhase != "" {
+ t.Fatalf("cut on nil inputs: %v %+v", err, rec.Move)
+ }
+ rec.Move.Inputs = &MoveInputs{Release: "v1"}
if err := MoveGuardedCut(g)(rec); err != nil || rec.Move.State != MoveCutting || rec.Move.CutPhase != CutRequested {
t.Fatalf("cut from ready: %v %+v", err, rec.Move)
}
@@ -257,3 +262,273 @@ func TestManifestReadyPathDefault(t *testing.T) {
t.Fatal("empty declaration keeps the default")
}
}
+
+func cutTestGuard() MoveGuard {
+ return MoveGuard{ID: "mv_abcdefghijklmnopqrstuvwxyz", Incarnation: strings.Repeat("ab", 32)}
+}
+
+func TestCutPhasesAdvanceInOrderUnderTheGuard(t *testing.T) {
+ g := cutTestGuard()
+ now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ rec := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveReady, Inputs: &MoveInputs{Release: "v1"}}}
+ if err := MoveGuardedCut(g)(rec); err != nil || rec.Move.State != MoveCutting || rec.Move.CutPhase != CutRequested {
+ t.Fatalf("auto cut: %v %+v", err, rec.Move)
+ }
+ deadline := now.Add(115 * time.Second)
+ if err := MoveAdmit(g, now, 120, deadline)(rec); err != nil || rec.Move.CutPhase != CutAdmitted || !rec.Move.CutDeadline.Equal(deadline) || !rec.Move.CutAt.Equal(now) || rec.Move.CutBudgetS != 120 || rec.Move.Inputs.Release != "v1" {
+ t.Fatalf("admit: %v %+v", err, rec.Move)
+ }
+ // Wrong incarnation: refused.
+ if err := MoveCutPhase(MoveGuard{ID: g.ID, Incarnation: strings.Repeat("cd", 32)}, CutAdmitted, CutStopping, nil)(rec); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("wrong guard: %v", err)
+ }
+ // Skipping a phase: refused.
+ if err := MoveCutPhase(g, CutStopping, CutStopped, nil)(rec); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("skip: %v", err)
+ }
+ // Source digests are recorded at stopped only.
+ if err := MoveRecordSource(g, MoveSource{FinalTXID: 1})(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Source != nil {
+ t.Fatalf("source before stopped: %v", err)
+ }
+ for _, step := range [][2]string{{CutAdmitted, CutStopping}, {CutStopping, CutStopped}} {
+ if err := MoveCutPhase(g, step[0], step[1], nil)(rec); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := MoveRecordSource(g, MoveSource{FinalTXID: 42, TimersSHA256: "sha256:t"})(rec); err != nil || rec.Move.Source.FinalTXID != 42 {
+ t.Fatalf("source: %v", err)
+ }
+ for _, step := range [][2]string{{CutStopped, CutStaged}, {CutStaged, CutArchived}} {
+ if err := MoveCutPhase(g, step[0], step[1], nil)(rec); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // Abort is refused past requested.
+ if err := MoveGuardedAbort(g)(rec); !errors.Is(err, ErrMoveConflict) {
+ t.Fatal("abort past admitted")
+ }
+ // The cut cannot step to flipped: only MoveFlip writes it.
+ if err := MoveCutPhase(g, CutArchived, CutFlipped, nil)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.CutPhase != CutArchived {
+ t.Fatalf("a phase step to flipped: %v", err)
+ }
+ ld := now.Add(10 * time.Minute)
+ if err := MoveFlip(g, now, ld)(rec); err != nil || rec.Region != "uk" || rec.Move.State != MoveLanding || rec.Move.CutPhase != CutFlipped || rec.Move.LandingPhase != LandChecking || !rec.Move.LandingDeadline.Equal(ld) || !rec.Move.FlippedAt.Equal(now) {
+ t.Fatalf("flip: %v %+v", err, rec)
+ }
+ if err := MoveFlip(g, now, ld)(rec); !errors.Is(err, ErrMoveConflict) {
+ t.Fatal("a second flip")
+ }
+ if err := MoveLandingPhase(g, LandChecking, LandParking, nil)(rec); err != nil || rec.Move.LandingPhase != LandParking {
+ t.Fatalf("landing phase: %v", err)
+ }
+ if err := MoveLandingPhase(g, LandParking, LandPublished, nil)(rec); !errors.Is(err, ErrMoveConflict) {
+ t.Fatal("a landing phase skipped")
+ }
+ stamped := false
+ if err := MoveLandingPhase(g, LandParking, LandBuilding, func(m *MoveState) { stamped = m.LandingPhase == LandBuilding })(rec); err != nil || !stamped {
+ t.Fatalf("landing stamp: %v", err)
+ }
+ if err := MoveLandingPhase(MoveGuard{ID: g.ID, Incarnation: strings.Repeat("cd", 32)}, LandBuilding, LandPublished, nil)(rec); !errors.Is(err, ErrMoveConflict) {
+ t.Fatal("landing phase under a stale guard")
+ }
+}
+
+// Every legal cut and landing step, and only those, under both orders.
+func TestCutAndLandingStepsAreTheOrderedPairsOnly(t *testing.T) {
+ g := cutTestGuard()
+ cuts := []string{CutRequested, CutAdmitted, CutStopping, CutStopped, CutStaged, CutArchived}
+ all := append(append([]string{}, cuts...), CutFlipped, "")
+ for _, from := range all {
+ for _, to := range all {
+ rec := &Record{Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveCutting, CutPhase: from, Inputs: &MoveInputs{}}}
+ err := MoveCutPhase(g, from, to, nil)(rec)
+ want := false
+ for i := 0; i+1 < len(cuts); i++ {
+ if cuts[i] == from && cuts[i+1] == to {
+ want = true
+ }
+ }
+ // archived to flipped is MoveFlip's, not a phase step's.
+ if from == CutArchived && to == CutFlipped {
+ want = false
+ }
+ if (err == nil) != want {
+ t.Fatalf("cut %q to %q: err %v, want ok=%v", from, to, err, want)
+ }
+ }
+ }
+ lands := []string{LandChecking, LandParking, LandBuilding, LandPublished, LandLocal, LandConfigured, LandRestored, LandProbing, LandProbed}
+ for i, from := range lands {
+ for j, to := range lands {
+ rec := &Record{Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveLanding, LandingPhase: from}}
+ err := MoveLandingPhase(g, from, to, nil)(rec)
+ if (err == nil) != (j == i+1) {
+ t.Fatalf("landing %q to %q: err %v", from, to, err)
+ }
+ }
+ }
+ // A landing step from a state that is not landing is refused.
+ rec := &Record{Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveCutting, LandingPhase: LandChecking}}
+ if err := MoveLandingPhase(g, LandChecking, LandParking, nil)(rec); !errors.Is(err, ErrMoveConflict) {
+ t.Fatal("landing step in cutting")
+ }
+}
+
+func TestBackToReadyAndRevert(t *testing.T) {
+ g := cutTestGuard()
+ now := time.Now()
+ rec := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveCutting, CutPhase: CutAdmitted, CutAt: &now, CutBudgetS: 120, CutDeadline: &now, Inputs: &MoveInputs{Release: "v1"}}}
+ if err := MoveBackToReady(g)(rec); err != nil || rec.Move.State != MoveReady || rec.Move.CutPhase != "" || rec.Move.CutAt != nil || rec.Move.CutBudgetS != 0 || rec.Move.CutDeadline != nil || rec.Move.Inputs != nil {
+ t.Fatalf("back to ready: %v %+v", err, rec.Move)
+ }
+ if err := MoveBackToReady(g)(rec); !errors.Is(err, ErrMoveConflict) {
+ t.Fatal("back to ready from ready")
+ }
+ for _, ph := range []string{CutStopping, CutStopped, CutStaged, CutArchived} {
+ rec.Move.State, rec.Move.CutPhase = MoveCutting, ph
+ if err := MoveBackToReady(g)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.State != MoveCutting {
+ t.Fatalf("back to ready from %s: %v", ph, err)
+ }
+ if err := MoveRevert(g, RevertStopEvidenceBudget)(rec); err != nil || rec.Move.State != MoveReverting || rec.Move.Reason != "stop evidence budget" {
+ t.Fatalf("revert from %s: %v %+v", ph, err, rec.Move)
+ }
+ }
+ // Not from requested or admitted: nothing ran yet, so back-to-ready is the exit there.
+ for _, ph := range []string{CutRequested, CutAdmitted, CutFlipped, ""} {
+ rec2 := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveCutting, CutPhase: ph}}
+ if err := MoveRevert(g, "x")(rec2); !errors.Is(err, ErrMoveConflict) || rec2.Move.State != MoveCutting {
+ t.Fatalf("revert from %q: %v", ph, err)
+ }
+ }
+ // Only a recorded sentence can be the reason: anything else is refused
+ // and nothing is written.
+ rec4 := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveCutting, CutPhase: CutStopped}}
+ if err := MoveRevert(g, "some error text")(rec4); err == nil || errors.Is(err, ErrMoveConflict) || rec4.Move.State != MoveCutting || rec4.Move.Reason != "" {
+ t.Fatalf("an unrecorded reason: %v %+v", err, rec4.Move)
+ }
+ // Revert is a cut's recovery only.
+ rec3 := &Record{Region: "uk", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveLanding, CutPhase: CutFlipped}}
+ if err := MoveRevert(g, "x")(rec3); !errors.Is(err, ErrMoveConflict) {
+ t.Fatal("revert from landing")
+ }
+}
+
+// G1: the whole loop. Inputs pinned at readiness, drift found at admission,
+// back to ready (Inputs nil), repin, cut, admit.
+func TestInputsLoopPinDriftBackRepinCutAdmit(t *testing.T) {
+ g := cutTestGuard()
+ now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ pin := MoveInputs{BundleETags: map[string]string{"app": "e1"}, Release: "v1"}
+ rec := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MovePreparing}}
+ if err := MoveGuardedPromoteReady(g, now, pin)(rec); err != nil || rec.Move.Inputs == nil {
+ t.Fatalf("pin: %v", err)
+ }
+ // Repin is legal only with Inputs nil.
+ if err := MoveGuardedRepin(g, MoveInputs{Release: "v2"})(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Inputs.Release != "v1" {
+ t.Fatalf("repin over a pin: %v", err)
+ }
+ if err := MoveGuardedCut(g)(rec); err != nil {
+ t.Fatal(err)
+ }
+ // Drift found: back to ready clears the pin.
+ if err := MoveBackToReady(g)(rec); err != nil || rec.Move.Inputs != nil {
+ t.Fatalf("drift, back to ready: %v %+v", err, rec.Move)
+ }
+ // A ready move with no pin cannot be cut.
+ if err := MoveGuardedCut(g)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.State != MoveReady {
+ t.Fatalf("cut on nil inputs: %v", err)
+ }
+ // Repin: guarded, legal in ready only.
+ if err := MoveGuardedRepin(MoveGuard{ID: g.ID, Incarnation: strings.Repeat("cd", 32)}, MoveInputs{Release: "v2"})(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Inputs != nil {
+ t.Fatalf("repin under a stale guard: %v", err)
+ }
+ fresh := MoveInputs{BundleETags: map[string]string{"app": "e2"}, Release: "v2"}
+ if err := MoveGuardedRepin(g, fresh)(rec); err != nil || rec.Move.Inputs == nil || rec.Move.Inputs.Release != "v2" || rec.Move.State != MoveReady {
+ t.Fatalf("repin: %v %+v", err, rec.Move)
+ }
+ fresh.BundleETags["app"] = "changed"
+ if rec.Move.Inputs.BundleETags["app"] != "e2" {
+ t.Fatal("the repin aliases the caller's map")
+ }
+ if err := MoveGuardedRepin(g, MoveInputs{Release: "v3"})(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Inputs.Release != "v2" {
+ t.Fatalf("second repin: %v", err)
+ }
+ if err := MoveGuardedCut(g)(rec); err != nil {
+ t.Fatal(err)
+ }
+ if err := MoveGuardedRepin(g, MoveInputs{Release: "v3"})(rec); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("repin in cutting: %v", err)
+ }
+ // Admit pins nothing: it stamps the clock fields and keeps the pin.
+ if err := MoveAdmit(g, now, 120, now.Add(time.Minute))(rec); err != nil || rec.Move.CutPhase != CutAdmitted || rec.Move.Inputs.Release != "v2" {
+ t.Fatalf("admit: %v %+v", err, rec.Move)
+ }
+ // Admit on a cut with no pin is refused and writes nothing.
+ bare := &Record{Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveCutting, CutPhase: CutRequested}}
+ if err := MoveAdmit(g, now, 120, now.Add(time.Minute))(bare); !errors.Is(err, ErrMoveConflict) || bare.Move.CutPhase != CutRequested || bare.Move.CutAt != nil || bare.Move.CutBudgetS != 0 {
+ t.Fatalf("admit with no pin: %v %+v", err, bare.Move)
+ }
+}
+
+// G18: the flip refuses any state but cutting/archived, so a record decision
+// written first (the C7 conversion's pre-flip guard, a revert, an abort)
+// wins over a flip that was already in flight.
+func TestFlipRefusesAnyStateButCuttingArchived(t *testing.T) {
+ g := cutTestGuard()
+ now := time.Now()
+ for _, c := range []struct{ state, phase string }{
+ {MoveReverting, CutArchived}, {MoveAborting, CutArchived}, {MoveRollingBack, CutArchived}, {MoveStuck, CutArchived},
+ {MoveReady, ""}, {MoveCutting, CutStaged}, {MoveCutting, CutRequested}, {MoveCutting, CutFlipped}, {MoveLanding, CutFlipped},
+ } {
+ rec := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: c.state, CutPhase: c.phase}}
+ if err := MoveFlip(g, now, now.Add(time.Minute))(rec); !errors.Is(err, ErrMoveConflict) || rec.Region != "ie" || rec.Move.State != c.state || rec.Move.FlippedAt != nil {
+ t.Fatalf("flip from %s/%s: %v region %s", c.state, c.phase, err, rec.Region)
+ }
+ }
+ // The sequence that matters: a revert is recorded, then the late flip.
+ rec := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveCutting, CutPhase: CutArchived}}
+ if err := MoveRevert(g, RevertStopEvidenceBudget)(rec); err != nil {
+ t.Fatal(err)
+ }
+ if err := MoveFlip(g, now, now.Add(time.Minute))(rec); !errors.Is(err, ErrMoveConflict) || rec.Region != "ie" || rec.Move.State != MoveReverting {
+ t.Fatalf("late flip after a revert: %v %+v", err, rec)
+ }
+ // And a stale guard cannot flip an archived cut.
+ rec = &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveCutting, CutPhase: CutArchived}}
+ if err := MoveFlip(MoveGuard{ID: g.ID, Incarnation: strings.Repeat("cd", 32)}, now, now.Add(time.Minute))(rec); !errors.Is(err, ErrMoveConflict) || rec.Region != "ie" {
+ t.Fatalf("flip under a stale guard: %v", err)
+ }
+}
+
+// The transitions run through SetRecordMove end to end, and the flip's
+// region change and state change land in one record write.
+func TestFlipIsOneRecordWrite(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ rec := moveTestRecord(t, s)
+ g := cutTestGuard()
+ now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ ctx := context.Background()
+ seq := []func(*Record) error{
+ MoveStartTransition(MoveStart{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", Cut: "auto", By: "ops@example.com", Now: now}),
+ MoveGuardedPromoteReady(g, now, MoveInputs{Release: "v1"}),
+ MoveGuardedCut(g),
+ MoveAdmit(g, now, 120, now.Add(115*time.Second)),
+ MoveCutPhase(g, CutAdmitted, CutStopping, nil),
+ MoveCutPhase(g, CutStopping, CutStopped, nil),
+ MoveCutPhase(g, CutStopped, CutStaged, nil),
+ MoveCutPhase(g, CutStaged, CutArchived, nil),
+ }
+ for i, tr := range seq {
+ if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, tr); err != nil {
+ t.Fatalf("step %d: %v", i, err)
+ }
+ }
+ got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, MoveFlip(g, now, now.Add(10*time.Minute)))
+ if err != nil || got.Region != "uk" || got.Move.State != MoveLanding {
+ t.Fatalf("flip: %v %+v", err, got)
+ }
+ read, _ := GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
+ if read.Region != "uk" || read.Move.State != MoveLanding || read.Move.LandingPhase != LandChecking || read.Move.Inputs == nil || read.Move.Inputs.Release != "v1" {
+ t.Fatalf("persisted: %+v", read.Move)
+ }
+}
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 37ac2e594..e3283abe2 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -1225,14 +1225,7 @@ func MoveGuardedPromoteReady(g MoveGuard, now time.Time, in MoveInputs) func(*Re
if err := MovePromoteReady(now)(rec); err != nil {
return err
}
- pinned := in
- if in.BundleETags != nil {
- pinned.BundleETags = make(map[string]string, len(in.BundleETags))
- for k, v := range in.BundleETags {
- pinned.BundleETags[k] = v
- }
- }
- rec.Move.Inputs = &pinned
+ rec.Move.Inputs = pinInputs(in)
return nil
}
}
@@ -1247,11 +1240,47 @@ func MoveGuardedCut(g MoveGuard) func(*Record) error {
if rec.Move.State != MoveReady {
return fmt.Errorf("%w: cut is only legal from ready", ErrMoveConflict)
}
+ // Spec §7.4 pins at readiness: a ready move with no pinned inputs
+ // (after a back-to-ready, until the repin) has nothing for the
+ // admission to compare with, so it is not cut.
+ if rec.Move.Inputs == nil {
+ return fmt.Errorf("%w: the move has no pinned inputs to cut against", ErrMoveConflict)
+ }
rec.Move.State, rec.Move.CutPhase = MoveCutting, CutRequested
return nil
}
}
+// pinInputs copies in, BundleETags map included, so the journal never
+// aliases a caller's map.
+func pinInputs(in MoveInputs) *MoveInputs {
+ pinned := in
+ if in.BundleETags != nil {
+ pinned.BundleETags = make(map[string]string, len(in.BundleETags))
+ for k, v := range in.BundleETags {
+ pinned.BundleETags[k] = v
+ }
+ }
+ return &pinned
+}
+
+// MoveGuardedRepin pins fresh inputs on a ready move that has none: the
+// state MoveBackToReady leaves after the admission found the pin stale.
+// Legal in ready with Inputs == nil only; a pin already there is never
+// overwritten (the pin is what the admission compares with).
+func MoveGuardedRepin(g MoveGuard, in MoveInputs) func(*Record) error {
+ return func(rec *Record) error {
+ if err := g.check(rec); err != nil {
+ return err
+ }
+ if rec.Move.State != MoveReady || rec.Move.Inputs != nil {
+ return fmt.Errorf("%w: inputs are re-pinned only on a ready move that has none", ErrMoveConflict)
+ }
+ rec.Move.Inputs = pinInputs(in)
+ return nil
+ }
+}
+
// MoveGuardedAbort: legal in preparing, ready and cutting/requested (§7.11).
func MoveGuardedAbort(g MoveGuard) func(*Record) error {
return func(rec *Record) error {
@@ -1289,6 +1318,177 @@ func MoveGuardedAbortComplete(g MoveGuard, now time.Time) func(*Record) error {
}
}
+// cutOrder is the cut's phase order up to archived. flipped is not a step
+// of it: the flip is MoveFlip's, one record write that also moves the
+// region, so MoveCutPhase never writes it.
+var cutOrder = []string{CutRequested, CutAdmitted, CutStopping, CutStopped, CutStaged, CutArchived}
+
+var landingOrder = []string{LandChecking, LandParking, LandBuilding, LandPublished, LandLocal, LandConfigured, LandRestored, LandProbing, LandProbed}
+
+func nextIn(order []string, from, to string) bool {
+ for i := 0; i+1 < len(order); i++ {
+ if order[i] == from && order[i+1] == to {
+ return true
+ }
+ }
+ return false
+}
+
+// MoveCutPhase advances cut_phase one step, in order, under the guard
+// (§7.4). stamp may fill the fields the phase records; it runs after the
+// checks, on the journal the step has just advanced.
+func MoveCutPhase(g MoveGuard, from, to string, stamp func(*MoveState)) func(*Record) error {
+ return func(rec *Record) error {
+ if err := g.check(rec); err != nil {
+ return err
+ }
+ if rec.Move.State != MoveCutting || rec.Move.CutPhase != from || !nextIn(cutOrder, from, to) {
+ return fmt.Errorf("%w: cut is %s/%s, step expects %s to %s", ErrMoveConflict, rec.Move.State, rec.Move.CutPhase, from, to)
+ }
+ rec.Move.CutPhase = to
+ if stamp != nil {
+ stamp(rec.Move)
+ }
+ return nil
+ }
+}
+
+// MoveAdmit is §7.4 requested to admitted: the budget and the absolute
+// deadline, in one write. It pins nothing: the inputs were pinned at
+// readiness (MoveGuardedPromoteReady, or MoveGuardedRepin after a back to
+// ready), and the source compares the current inputs with that pin before
+// this CAS. A cut with no pin is refused.
+func MoveAdmit(g MoveGuard, now time.Time, budgetS int, deadline time.Time) func(*Record) error {
+ return func(rec *Record) error {
+ if err := g.check(rec); err != nil {
+ return err
+ }
+ if rec.Move.Inputs == nil {
+ return fmt.Errorf("%w: the move has no pinned inputs to admit against", ErrMoveConflict)
+ }
+ return MoveCutPhase(g, CutRequested, CutAdmitted, func(m *MoveState) {
+ t, d := now.UTC(), deadline.UTC()
+ m.CutAt, m.CutBudgetS, m.CutDeadline = &t, budgetS, &d
+ })(rec)
+ }
+}
+
+// MoveBackToReady undoes an admission whose checks are no longer fresh or
+// whose inputs moved (§7.4 admitted). Legal from requested or admitted
+// only: nothing has stopped yet. It clears the cut fields and the pinned
+// inputs; the next readiness round pins fresh ones (MoveGuardedRepin).
+func MoveBackToReady(g MoveGuard) func(*Record) error {
+ return func(rec *Record) error {
+ if err := g.check(rec); err != nil {
+ return err
+ }
+ if rec.Move.State != MoveCutting || (rec.Move.CutPhase != CutRequested && rec.Move.CutPhase != CutAdmitted) {
+ return fmt.Errorf("%w: back to ready is legal from requested or admitted only", ErrMoveConflict)
+ }
+ m := rec.Move
+ m.State, m.CutPhase, m.CutAt, m.CutBudgetS, m.CutDeadline, m.Inputs = MoveReady, "", nil, 0, nil, nil
+ return nil
+ }
+}
+
+// MoveRecordSource writes the stopped source's baselines (§7.4 stopped).
+func MoveRecordSource(g MoveGuard, src MoveSource) func(*Record) error {
+ return func(rec *Record) error {
+ if err := g.check(rec); err != nil {
+ return err
+ }
+ if rec.Move.State != MoveCutting || rec.Move.CutPhase != CutStopped {
+ return fmt.Errorf("%w: source digests are recorded at stopped", ErrMoveConflict)
+ }
+ s := src
+ if src.Files != nil {
+ f := *src.Files
+ s.Files = &f
+ }
+ rec.Move.Source = &s
+ return nil
+ }
+}
+
+// MoveFlip is the flip (§7.4, §7.5): region = to, state = landing, the
+// landing deadline and landing_phase = checking, in one record write. The
+// claim follows (hostclaim.Flip).
+//
+// It refuses any state but cutting/archived, whoever holds the guard. So a
+// record decision written first, such as the C7 conversion's pre-flip guard
+// or a revert, wins over a flip already in flight: the late flip finds the
+// state changed and is refused, and the region is untouched.
+func MoveFlip(g MoveGuard, now, landingDeadline time.Time) func(*Record) error {
+ return func(rec *Record) error {
+ if err := g.check(rec); err != nil {
+ return err
+ }
+ if rec.Move.State != MoveCutting || rec.Move.CutPhase != CutArchived {
+ return fmt.Errorf("%w: the flip follows archived", ErrMoveConflict)
+ }
+ t, d := now.UTC(), landingDeadline.UTC()
+ rec.Region = rec.Move.To
+ m := rec.Move
+ m.State, m.CutPhase, m.FlippedAt, m.LandingDeadline, m.LandingPhase = MoveLanding, CutFlipped, &t, &d, LandChecking
+ return nil
+ }
+}
+
+// MoveLandingPhase advances landing_phase one step, in order, under the
+// guard (§7.6). stamp runs after the checks.
+func MoveLandingPhase(g MoveGuard, from, to string, stamp func(*MoveState)) func(*Record) error {
+ return func(rec *Record) error {
+ if err := g.check(rec); err != nil {
+ return err
+ }
+ if rec.Move.State != MoveLanding || rec.Move.LandingPhase != from || !nextIn(landingOrder, from, to) {
+ return fmt.Errorf("%w: landing is %s/%s, step expects %s to %s", ErrMoveConflict, rec.Move.State, rec.Move.LandingPhase, from, to)
+ }
+ rec.Move.LandingPhase = to
+ if stamp != nil {
+ stamp(rec.Move)
+ }
+ return nil
+ }
+}
+
+// The reasons a revert may record. MoveState.Reason reaches an operator
+// (`carlos move status`), so it is a recorded sentence (copy record MV-N158
+// onward), never error text; the copy gate reads every write to a field
+// named Reason and cannot read a parameter. A later task that needs another
+// reason adds a constant, a case below and a row in the copy record.
+const (
+ RevertStopEvidenceBudget = "stop evidence budget"
+)
+
+// MoveRevert starts the pre-flip recovery (§7.4 reverting): from a cut
+// phase that has begun stopping the source. Before that, back to ready is
+// the exit; after the flip, a rollback is. reason must be one of the
+// Revert constants above: any other text is refused, and nothing is written.
+func MoveRevert(g MoveGuard, reason string) func(*Record) error {
+ return func(rec *Record) error {
+ if err := g.check(rec); err != nil {
+ return err
+ }
+ if rec.Move.State != MoveCutting {
+ return fmt.Errorf("%w: revert is a cut's recovery", ErrMoveConflict)
+ }
+ switch rec.Move.CutPhase {
+ case CutStopping, CutStopped, CutStaged, CutArchived:
+ default:
+ return fmt.Errorf("%w: revert is legal from stopping to archived only", ErrMoveConflict)
+ }
+ switch reason {
+ case RevertStopEvidenceBudget:
+ rec.Move.Reason = RevertStopEvidenceBudget
+ default:
+ return fmt.Errorf("provision: revert reason %q has no recorded sentence", reason)
+ }
+ rec.Move.State = MoveReverting
+ 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/