carlos / platform Public

provision: the move journal of spec §7.2; MoveStart beside MoveRequest, guarded transitions beside the legacy ones

The legacy transitions, their tests and the staging-wins cut tests are untouched (index D13).
Adds Record.LastMove, InstanceStoreEnv, Manifest.Moves and the copy rows for the new 409 sentences.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 20df6801819329721d701d8bbca4d254ac35dbf6 parent 59f612f
3 files changed, +590 −9
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +4 −0
  • internal/provision/move_test.go +259 −0
  • internal/provision/provision.go +327 −9
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 64fd8fe9f..3095bb7f0 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -51,6 +51,10 @@ Columns: id, file, text, status.
| MV-N9 | internal/host/movespass.go | a staging lane for this move id already exists | pending |
| MV-N10 | internal/host/systemapply.go | the moves floor is unknown and this box holds an operation | pending |
| MV-N11 | internal/console/apiinstancemove.go | declare-lost is not enabled in this deployment yet | pending |
+| MV-N12 | internal/provision/provision.go | no journaled move in flight | pending |
+| MV-N13 | internal/provision/provision.go | the record's move is <id> for incarnation <incarnation> | pending |
+| MV-N14 | internal/provision/provision.go | malformed move start | pending |
+| MV-N15 | internal/provision/provision.go | past the point of no return. the cut completes forward or moves back on its own | pending |
## Plan 2
diff --git a/internal/provision/move_test.go b/internal/provision/move_test.go
new file mode 100644
index 000000000..b8b3e5419
--- /dev/null
+++ b/internal/provision/move_test.go
@@ -0,0 +1,259 @@
+package provision
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func moveTestRecord(t *testing.T, s release.Store) Record {
+ t.Helper()
+ rec := Record{Schema: 1, Host: "a.go.example.com", Slug: "s", App: "app", Account: "bab", Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC()}
+ if err := CreateRecord(context.Background(), s, rec); err != nil {
+ t.Fatal(err)
+ }
+ return rec
+}
+
+func TestMoveStartWritesTheJournalHead(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ rec := moveTestRecord(t, s)
+ now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ p := MoveStart{ID: "mv_abcdefghijklmnopqrstuvwxyz", Incarnation: strings.Repeat("ab", 32), From: "ie", To: "uk", Cut: "auto", By: "ops@example.com", Now: now}
+ got, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, MoveStartTransition(p))
+ if err != nil {
+ t.Fatal(err)
+ }
+ m := got.Move
+ if m.ID != p.ID || m.Incarnation != p.Incarnation || m.From != "ie" || m.To != "uk" || m.Cut != "auto" || m.RequestedBy != p.By || m.State != MovePreparing || !m.RequestedAt.Equal(now) || m.UpdatedAt.IsZero() || m.Legacy() {
+ t.Fatalf("journal head: %+v", m)
+ }
+ if got.Region != "ie" {
+ t.Fatal("the request moved the region")
+ }
+ // Refusals: in flight, same region, a replica, a bad id, a bad cut mode.
+ for name, bad := range map[string]MoveStart{
+ "in flight": p,
+ } {
+ if _, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, MoveStartTransition(bad)); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("%s: err %v, want ErrMoveConflict", name, err)
+ }
+ }
+ s2 := release.NewDirStore(t.TempDir())
+ rec2 := moveTestRecord(t, s2)
+ for name, bad := range map[string]MoveStart{
+ "same region": {ID: p.ID, Incarnation: p.Incarnation, From: "ie", To: "ie", Cut: "auto", Now: now},
+ "bad id": {ID: "mv_short", Incarnation: p.Incarnation, From: "ie", To: "uk", Cut: "auto", Now: now},
+ "bad cut": {ID: p.ID, Incarnation: p.Incarnation, From: "ie", To: "uk", Cut: "sometime", Now: now},
+ "wrong from": {ID: p.ID, Incarnation: p.Incarnation, From: "us", To: "uk", Cut: "auto", Now: now},
+ "no inc": {ID: p.ID, To: "uk", From: "ie", Cut: "auto", Now: now},
+ } {
+ if _, err := SetRecordMove(context.Background(), s2, rec2.Account, rec2.App, rec2.Host, MoveStartTransition(bad)); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("%s: err %v, want ErrMoveConflict", name, err)
+ }
+ }
+ replica := Record{Schema: 1, Host: "r.go.example.com", Slug: "s", App: "app", Account: "bab", Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC(), Role: "replica"}
+ if err := CreateRecord(context.Background(), s2, replica); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetRecordMove(context.Background(), s2, "bab", "app", replica.Host, MoveStartTransition(p)); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("replica: %v", err)
+ }
+}
+
+// Review Focus 1: a move a v0.22 console wrote has no id. The journal
+// calls it legacy; the legacy transitions still drive it (the legacy path
+// stays while the switch is off, Task 0), and no guarded transition
+// accepts it.
+func TestMoveStateLegacyRecordIsRefusedByTheGuard(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ rec := moveTestRecord(t, s)
+ if _, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, MoveRequest("uk", time.Now())); err != nil {
+ t.Fatal(err)
+ }
+ got, _ := GetRecord(context.Background(), s, rec.Account, rec.App, rec.Host)
+ if !got.Move.Legacy() {
+ t.Fatal("an id-less move is legacy")
+ }
+ g := MoveGuard{ID: "mv_abcdefghijklmnopqrstuvwxyz", Incarnation: strings.Repeat("ab", 32)}
+ for name, tr := range map[string]func(*Record) error{
+ "promote": MoveGuardedPromoteReady(g, time.Now(), MoveInputs{}),
+ "cut": MoveGuardedCut(g),
+ "abort": MoveGuardedAbort(g),
+ "abort complete": MoveGuardedAbortComplete(g, time.Now()),
+ // A guard with no id at all must not match an id-less move either.
+ "promote, empty guard": MoveGuardedPromoteReady(MoveGuard{}, time.Now(), MoveInputs{}),
+ } {
+ if _, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, tr); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("guarded %s on a legacy move: %v", name, err)
+ }
+ }
+ // The legacy transitions still work, unchanged.
+ if _, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, MovePromoteReady(time.Now())); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, MoveAbort()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, MoveAbortComplete()); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// The switch-off contract: a record written by the legacy transitions
+// carries exactly the journal keys main wrote, none of the new ones.
+func TestLegacyMoveRecordKeepsMainsKeys(t *testing.T) {
+ rec := &Record{Region: "ie"}
+ if err := MoveRequest("uk", time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC))(rec); err != nil {
+ t.Fatal(err)
+ }
+ body, err := json.Marshal(rec.Move)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := `{"to":"uk","state":"preparing","requested_at":"2026-10-06T09:00:00Z","updated_at":"0001-01-01T00:00:00Z"}`
+ if string(body) != want {
+ t.Fatalf("legacy journal drifted:\n got %s\nwant %s", body, want)
+ }
+ out, err := json.Marshal(Record{Region: "ie"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(out), "last_move") {
+ t.Fatalf("a record at rest grew last_move: %s", out)
+ }
+}
+
+func TestGuardedPromoteReadyPinsTheInputs(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)
+ in := MoveInputs{BundleETags: map[string]string{"app": "e1"}, OverlayETag: "o1", Release: "v1", FilesPlain: "f1", EnvReport: "r1"}
+ rec := &Record{Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MovePreparing}}
+ if err := MoveGuardedPromoteReady(g, now, in)(rec); err != nil {
+ t.Fatal(err)
+ }
+ m := rec.Move
+ if m.State != MoveReady || m.ReadyAt == nil || !m.ReadyAt.Equal(now) {
+ t.Fatalf("not ready: %+v", m)
+ }
+ if m.Inputs == nil || m.Inputs.OverlayETag != "o1" || m.Inputs.Release != "v1" || m.Inputs.FilesPlain != "f1" || m.Inputs.EnvReport != "r1" || m.Inputs.BundleETags["app"] != "e1" {
+ t.Fatalf("inputs not pinned: %+v", m.Inputs)
+ }
+ in.BundleETags["app"] = "changed"
+ if m.Inputs.BundleETags["app"] != "e1" {
+ t.Fatal("the pinned inputs alias the caller's map")
+ }
+ // A second promote is refused and does not re-pin.
+ if err := MoveGuardedPromoteReady(g, now, MoveInputs{Release: "v2"})(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Inputs.Release != "v1" {
+ t.Fatalf("second promote: %v %+v", err, rec.Move.Inputs)
+ }
+ // A refused promote leaves a preparing move without inputs.
+ other := &Record{Move: &MoveState{ID: "mv_bbbbbbbbbbbbbbbbbbbbbbbbbb", Incarnation: g.Incarnation, State: MovePreparing}}
+ if err := MoveGuardedPromoteReady(g, now, in)(other); !errors.Is(err, ErrMoveConflict) || other.Move.Inputs != nil || other.Move.State != MovePreparing {
+ t.Fatalf("guard mismatch: %v %+v", err, other.Move)
+ }
+}
+
+func TestGuardedCutAndAbortComplete(t *testing.T) {
+ g := MoveGuard{ID: "mv_abcdefghijklmnopqrstuvwxyz", Incarnation: strings.Repeat("ab", 32)}
+ rec := &Record{Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MovePreparing}}
+ if err := MoveGuardedCut(g)(rec); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("cut from preparing: %v", err)
+ }
+ rec.Move.State = MoveReady
+ 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)
+ }
+ if err := MoveGuardedCut(g)(rec); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("second cut: %v", err)
+ }
+ // Incarnation mismatch alone is refused.
+ stale := MoveGuard{ID: g.ID, Incarnation: strings.Repeat("cd", 32)}
+ if err := MoveGuardedAbort(stale)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.State != MoveCutting {
+ t.Fatalf("stale incarnation: %v", err)
+ }
+ // Abort from cutting/requested, then abort-complete records the outcome.
+ if err := MoveGuardedAbort(g)(rec); err != nil || rec.Move.State != MoveAborting {
+ t.Fatalf("abort: %v %+v", err, rec.Move)
+ }
+ now := time.Date(2026, 10, 6, 9, 5, 0, 0, time.UTC)
+ if err := MoveGuardedAbortComplete(g, now)(rec); err != nil {
+ t.Fatal(err)
+ }
+ if rec.Move != nil || rec.LastMove == nil || rec.LastMove.ID != g.ID || rec.LastMove.Outcome != "aborted" || !rec.LastMove.At.Equal(now) {
+ t.Fatalf("outcome: move %+v last %+v", rec.Move, rec.LastMove)
+ }
+ if err := MoveGuardedAbortComplete(g, now)(rec); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("abort-complete with no move: %v", err)
+ }
+}
+
+func TestGuardedAbortLegalStates(t *testing.T) {
+ g := MoveGuard{ID: "mv_abcdefghijklmnopqrstuvwxyz", Incarnation: strings.Repeat("ab", 32)}
+ cases := []struct {
+ state, cutPhase string
+ ok bool
+ }{
+ {MovePreparing, "", true}, {MoveReady, "", true},
+ {MoveCutting, CutRequested, true}, {MoveCutting, CutAdmitted, false},
+ {MoveLanding, "", false}, {MoveRollingBack, "", false}, {MoveReverting, "", false},
+ {MoveAborting, "", false}, {"future", "", false},
+ }
+ for _, c := range cases {
+ m := &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: c.state, CutPhase: c.cutPhase}
+ rec := &Record{Move: m}
+ err := MoveGuardedAbort(g)(rec)
+ if (err == nil) != c.ok {
+ t.Fatalf("abort from %s/%s: err %v", c.state, c.cutPhase, err)
+ }
+ }
+ // The legacy MoveAbort keeps its allowlist (P0's rule): cutting refused.
+ rec := &Record{Move: &MoveState{State: MoveCutting}}
+ if err := MoveAbort()(rec); !errors.Is(err, ErrMoveConflict) {
+ t.Fatal("legacy abort from cutting must stay refused")
+ }
+}
+
+func TestGuardedTransitionsRefuseAReplacementMove(t *testing.T) {
+ now := time.Now()
+ gA := MoveGuard{ID: "mv_aaaaaaaaaaaaaaaaaaaaaaaaaa", Incarnation: strings.Repeat("ab", 32)}
+ // A's worker observed preparing; the operator aborted A and requested
+ // B, which is preparing too.
+ rec := &Record{Region: "ie", Move: &MoveState{ID: "mv_bbbbbbbbbbbbbbbbbbbbbbbbbb", Incarnation: gA.Incarnation, From: "ie", To: "uk", State: MovePreparing}}
+ if err := MoveGuardedPromoteReady(gA, now, MoveInputs{})(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.State != MovePreparing {
+ t.Fatalf("A's round promoted B: %v %+v", err, rec.Move)
+ }
+ rec.Move.State = MoveAborting
+ if err := MoveGuardedAbortComplete(gA, now)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move == nil {
+ t.Fatalf("A's abort cleared B: %v", err)
+ }
+ if rec.LastMove != nil {
+ t.Fatalf("A's refused abort left an outcome: %+v", rec.LastMove)
+ }
+}
+
+func TestInstanceStoreEnv(t *testing.T) {
+ if got := InstanceStoreEnv(&Record{Slug: "s"}); got != "s" {
+ t.Fatalf("no environment: %q", got)
+ }
+ if got := InstanceStoreEnv(&Record{Slug: "s", Environment: "canary"}); got != "canary" {
+ t.Fatalf("named environment: %q", got)
+ }
+}
+
+func TestManifestReadyPathDefault(t *testing.T) {
+ if (Manifest{}).ReadyPathOrDefault() != "/" {
+ t.Fatal("default ready path")
+ }
+ if (Manifest{Moves: &MovesManifest{ReadyPath: "/healthz"}}).ReadyPathOrDefault() != "/healthz" {
+ t.Fatal("declared ready path")
+ }
+ if (Manifest{Moves: &MovesManifest{}}).ReadyPathOrDefault() != "/" {
+ t.Fatal("empty declaration keeps the default")
+ }
+}
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 2616cdfda..a20198f96 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -57,6 +57,14 @@ type Record struct {
// unchanged. Written ONLY through SetRecordMove's guarded transitions.
Move *MoveState `json:"move,omitempty"`
+ // LastMove is the terminal outcome the close CAS leaves as it clears
+ // Move (index D11), so a CLI that missed the intermediate states still
+ // reads how the move it requested ended. Written by the guarded close
+ // transitions only (MoveGuardedAbortComplete, and the done and
+ // rolled_back closes the later plans add). Additive and omitempty:
+ // absent on every record at rest on main.
+ LastMove *MoveOutcome `json:"last_move,omitempty"`
+
// --- shape fields (2026-08-10 route-records design §3) --------------
// Every one is omitempty with a zero value that reproduces the mint
// reconcile hardcoded before they existed: a record written by an older
@@ -251,6 +259,22 @@ type Manifest struct {
// every manifest in a production bucket already has. There is nothing
// for absence to mean that false does not already mean.
PrivateStorage bool `json:"private_storage,omitempty"`
+
+ // Moves is the app's move declaration (move-any-app design §7.3 S1):
+ // the readiness path a probe requests. Absent means "/".
+ Moves *MovesManifest `json:"moves,omitempty"`
+}
+
+type MovesManifest struct {
+ ReadyPath string `json:"ready_path,omitempty"`
+}
+
+// ReadyPathOrDefault is the probe's path (§7.7).
+func (m Manifest) ReadyPathOrDefault() string {
+ if m.Moves != nil && m.Moves.ReadyPath != "" {
+ return m.Moves.ReadyPath
+ }
+ return "/"
}
// ManifestKey, RecordsPrefix and RecordKey are the bucket key conventions
@@ -901,13 +925,22 @@ func updateRecord(ctx context.Context, s release.Store, acct, app, host string,
return rec, err
}
-// MoveState is the record's in-flight move (issue #331). State is one of
-// the Move* constants; To is the target placement code. ReadyAt is stamped
-// by the source box's preparing→ready promotion, so the tenant can see how
-// stale its freeze-window budget is.
+// MoveState is the record's move journal (move-any-app design §7.2). The
+// claim carries the cross-operation serialization; this field carries one
+// move's own phases, because only a move reads them. Every field after
+// UpdatedAt is additive and omitempty: a v0.22 console reads the ones it
+// knows and refuses to abort any state it does not (G16).
type MoveState struct {
- To string `json:"to"`
+ // ID is the move id (mv_…), Incarnation the holder incarnation the
+ // claim named when the move began. Both guard every box CAS (§7.2).
+ ID string `json:"id,omitempty"`
+ Incarnation string `json:"incarnation,omitempty"`
+ From string `json:"from,omitempty"`
+ To string `json:"to"`
+ // Cut is "auto" or "manual" (§7.1).
+ Cut string `json:"cut,omitempty"`
State string `json:"state"`
+ RequestedBy string `json:"requested_by,omitempty"`
RequestedAt time.Time `json:"requested_at"`
ReadyAt *time.Time `json:"ready_at,omitempty"`
// UpdatedAt is when the state last changed — stamped by SetRecordMove
@@ -915,11 +948,98 @@ type MoveState struct {
// console's flat move view can carry one freshness stamp without each
// transition re-implementing it.
UpdatedAt time.Time `json:"updated_at"`
+
+ // Inputs are the versions pinned at readiness and re-checked at
+ // admission (§7.3 T3, §7.4 admitted, INV-6).
+ Inputs *MoveInputs `json:"inputs,omitempty"`
+
+ CutPhase string `json:"cut_phase,omitempty"`
+ CutAt *time.Time `json:"cut_at,omitempty"`
+ CutBudgetS int `json:"cut_budget_s,omitempty"`
+ CutDeadline *time.Time `json:"cut_deadline,omitempty"`
+ Source *MoveSource `json:"source,omitempty"`
+
+ FlippedAt *time.Time `json:"flipped_at,omitempty"`
+ LandingDeadline *time.Time `json:"landing_deadline,omitempty"`
+ LandingPhase string `json:"landing_phase,omitempty"`
+ Landing *MoveLandingJournal `json:"landing,omitempty"`
+
+ CommittedAt *time.Time `json:"committed_at,omitempty"`
+ OpenedAt *time.Time `json:"opened_at,omitempty"`
+ SourceParkedAt *time.Time `json:"source_parked_at,omitempty"`
+
+ Rollback *MoveRollback `json:"rollback,omitempty"`
+ Reason string `json:"reason,omitempty"`
}
-// The move states, in order. There is no "done" state: the terminal
-// transition flips Region and CLEARS Move in one write — a record at rest
-// never says anything about moves.
+// MoveInputs (what the target was checked against, §7.3 T3) is NOT
+// defined here: it is main's internal/provision/moveinputs_contract.go
+// (claim Plan 6, d649d8d4), the shared contract this journal references.
+
+// MoveSource is what the source recorded at `stopped` (§7.4): the
+// baselines every recovery compares against.
+type MoveSource struct {
+ Replication string `json:"replication,omitempty"`
+ NextDue int64 `json:"next_due,omitempty"`
+ FinalTXID uint64 `json:"final_txid,omitempty"`
+ DBSHA256 string `json:"db_sha256,omitempty"`
+ Files *MoveFilesDigest `json:"files,omitempty"`
+ TimersSHA256 string `json:"timers_sha256,omitempty"`
+ Evidence string `json:"evidence,omitempty"`
+}
+
+type MoveFilesDigest struct {
+ Plain string `json:"plain,omitempty"`
+ Sealed string `json:"sealed,omitempty"`
+ Bytes int64 `json:"bytes,omitempty"`
+ Entries int `json:"entries,omitempty"`
+}
+
+// MoveLandingJournal is the landing's own record (§7.2 "landing"). Named
+// so because MoveLanding is the state constant below.
+type MoveLandingJournal struct {
+ InstalledDBSHA256 string `json:"installed_db_sha256,omitempty"`
+ Attempts int `json:"attempts,omitempty"`
+}
+
+// MoveOutcome is the terminal result the close CAS leaves on the record
+// as it clears Move (index D11), so a CLI that missed the intermediate
+// states still reads the outcome of the move it requested.
+type MoveOutcome struct {
+ ID string `json:"id"`
+ Outcome string `json:"outcome"` // done | rolled_back | aborted
+ At time.Time `json:"at"`
+ OfflineS float64 `json:"offline_s,omitempty"`
+}
+
+// MoveRollback is §7.8's journal.
+type MoveRollback struct {
+ ID string `json:"id,omitempty"`
+ RequestedAt *time.Time `json:"requested_at,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ TargetPhase string `json:"target_phase,omitempty"`
+ Dirty bool `json:"dirty,omitempty"`
+ Evidence string `json:"evidence,omitempty"`
+ SourcePhase string `json:"source_phase,omitempty"`
+ RecoveredAt *time.Time `json:"recovered_at,omitempty"`
+ OpenedAt *time.Time `json:"opened_at,omitempty"`
+ TargetParkedAt *time.Time `json:"target_parked_at,omitempty"`
+ ReleaseDeadline *time.Time `json:"release_deadline,omitempty"`
+ // RecoveryInputs are the inputs a recovery install rendered itself
+ // (§7.7, J2-R2-5); never compared with Inputs.
+ RecoveryInputs *MoveInputs `json:"recovery_inputs,omitempty"`
+}
+
+// Legacy reports a move journal written before ids existed (a v0.22
+// console). The guarded transitions below refuse it: a legacy record is
+// driven only by the legacy transitions (MovePromoteReady, MoveAbort,
+// MoveAbortComplete and the rest). The new door routes a Legacy() record
+// to the legacy MoveAbort, and the new worker completes a legacy aborting
+// record through the legacy code path only.
+func (m *MoveState) Legacy() bool { return m != nil && m.ID == "" }
+
+// The move states. There is no "done" state: the close CAS clears Move, so
+// a record at rest never says anything about moves.
const (
MovePreparing = "preparing"
MoveReady = "ready"
@@ -930,7 +1050,62 @@ const (
// a bare clear would win a LATER landing on that box (staging-wins,
// backend_exec.go's promoteStagedReplica) with data from an abandoned
// move.
- MoveAborting = "aborting"
+ MoveAborting = "aborting"
+ MoveReverting = "reverting"
+ MoveLanding = "landing"
+ MoveCommitted = "committed"
+ MoveRollingBack = "rolling_back"
+ MoveStuck = "stuck"
+)
+
+// Cut phases (§7.4), landing phases (§7.6), reverting phases (§7.4) and
+// rollback phases (§7.8). Each is written before its side effects.
+const (
+ CutRequested = "requested"
+ CutAdmitted = "admitted"
+ CutStopping = "stopping"
+ CutStopped = "stopped"
+ CutStaged = "staged"
+ CutArchived = "archived"
+ CutFlipped = "flipped"
+
+ LandChecking = "checking"
+ LandParking = "parking"
+ LandBuilding = "building"
+ LandPublished = "published"
+ LandLocal = "local"
+ LandConfigured = "configured"
+ LandRestored = "restored"
+ LandProbing = "probing"
+ LandProbed = "probed"
+
+ RevEvidence = "evidence"
+ RevVerify = "verify"
+ RevProbing = "probing"
+ RevProbed = "probed"
+ RevOpened = "opened"
+
+ RbTargetReleasing = "releasing"
+ RbTargetReleased = "released"
+ RbTargetReturning = "returning"
+ RbTargetReturned = "returned"
+ RbTargetCleaning = "cleaning"
+ RbTargetCleaned = "cleaned"
+
+ RbSourceWaiting = "waiting"
+ RbSourceClaimed = "claimed"
+ RbSourceRestoring = "restoring"
+ RbSourceVerified = "verified"
+ RbSourceParking = "parking"
+ RbSourceBuilding = "building"
+ RbSourcePublished = "published"
+ RbSourceLocal = "local"
+ RbSourceConfigured = "configured"
+ RbSourceRestored = "restored"
+ RbSourceProbing = "probing"
+ RbSourceProbed = "probed"
+ RbSourceRecovered = "recovered"
+ RbSourceOpened = "opened"
)
// ErrMoveConflict is a transition refused because the record's current move
@@ -984,6 +1159,149 @@ func MoveRequest(to string, now time.Time) func(*Record) error {
}
}
+// MoveStart is what the request door decided (§7.1, §7.2): the id it
+// minted, the incarnation the claim CAS named, the cut mode and who asked.
+type MoveStart struct {
+ ID, Incarnation, From, To, Cut, By string
+ Now time.Time
+}
+
+// MoveStartTransition starts a move. Refused when one is in flight, when
+// the target is the current region, for a replica (a replica never gets a
+// route on a platform box, G14), and for any malformed start: a move
+// without an id has no journal to run.
+func MoveStartTransition(p MoveStart) func(*Record) error {
+ return func(rec *Record) error {
+ if rec.Move != nil {
+ return fmt.Errorf("%w: a move to %s is already %s", ErrMoveConflict, rec.Move.To, rec.Move.State)
+ }
+ if !moveIDShape.MatchString(p.ID) || len(p.Incarnation) != 64 || (p.Cut != "auto" && p.Cut != "manual") || p.Now.IsZero() {
+ return fmt.Errorf("%w: malformed move start", ErrMoveConflict)
+ }
+ if rec.Region == p.To || p.From != rec.Region {
+ return fmt.Errorf("%w: the instance is already in %s", ErrMoveConflict, rec.Region)
+ }
+ if rec.Role != "" {
+ return fmt.Errorf("%w: a %s does not move. delete it and create one in the new region", ErrMoveConflict, rec.Role)
+ }
+ rec.Move = &MoveState{ID: p.ID, Incarnation: p.Incarnation, From: p.From, To: p.To, Cut: p.Cut, State: MovePreparing, RequestedBy: p.By, RequestedAt: p.Now.UTC()}
+ return nil
+ }
+}
+
+// moveIDShape is a move id: "mv_" and 26 lowercase base32 characters.
+//
+// Twin of moves.opIDShape (internal/moves/ids.go), which also accepts the
+// rollback prefix: the copy is forced, because moves imports provision, so
+// provision cannot import moves. Keep the two in step.
+var moveIDShape = regexp.MustCompile(`^mv_[a-z2-7]{26}$`)
+
+// MoveGuard is what every Phase 1 transition carries (§7.2): the move id
+// and the holder incarnation. A write whose guard does not match the
+// record's move is a late write for another move or another incarnation
+// and is refused, so a delayed round for move A can never advance a
+// replacement move B that happens to be in the same state.
+type MoveGuard struct{ ID, Incarnation string }
+
+func (g MoveGuard) check(rec *Record) error {
+ if rec.Move == nil || rec.Move.Legacy() {
+ return fmt.Errorf("%w: no journaled move in flight", ErrMoveConflict)
+ }
+ if rec.Move.ID != g.ID || rec.Move.Incarnation != g.Incarnation {
+ return fmt.Errorf("%w: the record's move is %s for incarnation %s", ErrMoveConflict, rec.Move.ID, rec.Move.Incarnation)
+ }
+ return nil
+}
+
+// MoveGuardedPromoteReady is the new worker's preparing → ready. It pins
+// the inputs the target was checked against onto the journal at readiness
+// (§7.2, §7.4), so the admission re-check has something to compare with.
+func MoveGuardedPromoteReady(g MoveGuard, now time.Time, in MoveInputs) func(*Record) error {
+ return func(rec *Record) error {
+ if err := g.check(rec); err != nil {
+ return err
+ }
+ 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
+ return nil
+ }
+}
+
+// MoveGuardedCut is the cut's entry (the console's cut door, or the source
+// for cut == "auto"): ready → cutting/requested.
+func MoveGuardedCut(g MoveGuard) func(*Record) error {
+ return func(rec *Record) error {
+ if err := g.check(rec); err != nil {
+ return err
+ }
+ if rec.Move.State != MoveReady {
+ return fmt.Errorf("%w: cut is only legal from ready", ErrMoveConflict)
+ }
+ rec.Move.State, rec.Move.CutPhase = MoveCutting, CutRequested
+ return nil
+ }
+}
+
+// MoveGuardedAbort: legal in preparing, ready and cutting/requested (§7.11).
+func MoveGuardedAbort(g MoveGuard) func(*Record) error {
+ return func(rec *Record) error {
+ if err := g.check(rec); err != nil {
+ return err
+ }
+ switch rec.Move.State {
+ case MovePreparing, MoveReady:
+ case MoveCutting:
+ if rec.Move.CutPhase != CutRequested {
+ return fmt.Errorf("%w: past the point of no return. the cut completes forward or moves back on its own", ErrMoveConflict)
+ }
+ case MoveAborting:
+ return fmt.Errorf("%w: already aborting", ErrMoveConflict)
+ default:
+ return fmt.Errorf("%w: a move in state %q cannot be aborted", ErrMoveConflict, rec.Move.State)
+ }
+ rec.Move.State = MoveAborting
+ return nil
+ }
+}
+
+// MoveGuardedAbortComplete clears an aborting move and records the outcome.
+func MoveGuardedAbortComplete(g MoveGuard, now time.Time) func(*Record) error {
+ return func(rec *Record) error {
+ if err := g.check(rec); err != nil {
+ return err
+ }
+ if rec.Move.State != MoveAborting {
+ return fmt.Errorf("%w: abort-complete is only legal from aborting", ErrMoveConflict)
+ }
+ rec.LastMove = &MoveOutcome{ID: g.ID, Outcome: "aborted", At: now.UTC()}
+ rec.Move = 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/
+// apiinstances.go, "env, derived := rec.Environment, false"); the door is
+// not refactored to call this, so keep the two in step. Shared by the
+// console's move door and the host round, which cannot see a console-local
+// helper.
+func InstanceStoreEnv(rec *Record) string {
+ if rec.Environment != "" {
+ return rec.Environment
+ }
+ return rec.Slug
+}
+
// MovePromoteReady is the source box's preparing→ready promotion.
func MovePromoteReady(now time.Time) func(*Record) error {
return func(rec *Record) error {