carlos / platform Public

moves-phase1-3

Squashed from moves-phase1-3 into main.
claude agent, operated by paul@keymail.dev 81d0cc609fbe24d0c9d8b5c2f986a93e925c8c4c parent 56ebee1
11 files changed, +814 −4
  • approved_moves_copy_test.go +2 −0
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +6 −1
  • docs/superpowers/specs/2026-10-02-move-any-app-design.md +4 −1
  • internal/activator/claimloop_linux_test.go +132 −0
  • internal/activator/claimwindow_test.go +88 −0
  • internal/hostclaim/move.go +55 −2
  • internal/hostclaim/move_test.go +147 −0
  • internal/moves/probe.go +38 −0
  • internal/moves/probe_test.go +30 −0
  • internal/provision/move_test.go +177 −0
  • internal/provision/provision.go +135 −0

CI passed — run details

diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 04db0fd77..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
@@ -620,6 +621,7 @@ var movesScanFiles = []string{
"internal/moves/journal.go",
"internal/moves/lease.go",
"internal/moves/ltxbuild.go",
+ "internal/moves/probe.go",
"internal/moves/readiness.go",
"internal/moves/seal.go",
"internal/moves/sealedclient.go",
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/docs/superpowers/specs/2026-10-02-move-any-app-design.md b/docs/superpowers/specs/2026-10-02-move-any-app-design.md
index c194bf885..36c92b479 100644
--- a/docs/superpowers/specs/2026-10-02-move-any-app-design.md
+++ b/docs/superpowers/specs/2026-10-02-move-any-app-design.md
@@ -2885,7 +2885,10 @@ runs anywhere. It converges when the target returns (INV-3).
### 7.9 Commit and finalization (codex 6)
1. **Commit** (target): record CAS `probed` → `committed`, conditional on
- `now ≤ cut_deadline` (§7.4); then the claim follows (§7.5): phase
+ `now ≤ cut_deadline` (§7.4). The commit is also refused once a rollback
+ reason is recorded, and it is bounded by the earlier of `cut_deadline`
+ and `landing_deadline` (§7.8 makes the second a rollback trigger; Plan 2
+ ruling P2-deadline). Then the claim follows (§7.5): phase
`committed`, plus the obligations in the same claim write,
`park-superseded` for the source's location in its region and box,
and `park-staging` for the target's staging (§19.3).
diff --git a/internal/activator/claimloop_linux_test.go b/internal/activator/claimloop_linux_test.go
new file mode 100644
index 000000000..159b1b862
--- /dev/null
+++ b/internal/activator/claimloop_linux_test.go
@@ -0,0 +1,132 @@
+//go:build linux
+
+package activator
+
+import (
+ "context"
+ "errors"
+ "os/exec"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// The start loop of startAdmitted, driven for real (ruling H17, fix round 1).
+//
+// What can and cannot be driven. RoleLaunch.Start (rolelaunch_linux.go)
+// returns ErrClaimIsolationUnavailable unconditionally, until claim Plan 9
+// Group D replaces the by-name refusal with the isolated-root launcher. It
+// is a value method on a concrete type, with no seam, so nothing a test can
+// supply runs inside it. Two of the loop's hooks run only from inside Start
+// or after it: beforeStart (which journals the attempt's first start through
+// OpenProbe and so opens the 30 s window) and Ready. Hence the window can
+// not be opened, and readiness can not be offered, through this entry point
+// today. The loop itself, ahead of and around Start, can be driven: the
+// refusal is an ordinary failed start, and these tests use it as one.
+// OpenProbe and Ready are tripwires: the day Start stops refusing they fail,
+// and the skipped test below is to be written then.
+
+type loopRun struct {
+ mu sync.Mutex
+ reserved []time.Time
+ failed int
+}
+
+func (r *loopRun) starts() []time.Time {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return append([]time.Time(nil), r.reserved...)
+}
+
+func driveLoop(t *testing.T, policy startPolicy) (*loopRun, error) {
+ t.Helper()
+ run := &loopRun{}
+ obs := hostclaim.Observed{Host: "x.example", ETag: "one", Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{Location: "instances/x.example/inc", Session: 1, Generation: "one"}, Op: hostclaim.Op{Kind: "landing", ID: "op", Phase: "probing", Exec: "box"}}}
+ work, _ := (hostwrite.Config{}).Select(t.Context(), obs.Host, "spawn")
+ a := &Service{cfg: Config{HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box", Read: func(context.Context, string) (hostclaim.Observed, error) { return obs, nil }, Prepare: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error) {
+ return ClaimStart{RoleLauncher: &RoleLaunch{}, CurrentAttempt: func() hostclaim.WakeRetryAttempt { return hostclaim.WakeRetryAttempt{} }, Host: obs.Host, Work: work, Identity: hostwrite.Identity{Config: "c", Release: "r"},
+ OpenProbe: func(context.Context, time.Time) (time.Time, error) {
+ t.Fatal("OpenProbe ran: Start no longer refuses, replace the skipped window test")
+ return time.Time{}, nil
+ },
+ Restore: func(context.Context) error { return nil },
+ Materialize: func(context.Context) error { return nil },
+ Build: func() *exec.Cmd { return nil },
+ Ready: func(context.Context, *exec.Cmd) error {
+ t.Fatal("Ready ran: Start no longer refuses, replace the skipped window test")
+ return nil
+ },
+ Reserve: func(context.Context, int) error {
+ run.mu.Lock()
+ run.reserved = append(run.reserved, time.Now())
+ run.mu.Unlock()
+ return nil
+ },
+ Failed: func(context.Context, int) error { run.mu.Lock(); run.failed++; run.mu.Unlock(); return nil },
+ Healthy: func(context.Context, int, *exec.Cmd) error { return nil },
+ Replicate: func(context.Context, string) (Rep, error) { return nil, nil },
+ Publish: func(context.Context, hostclaim.Observed) (hostclaim.Observed, error) { return obs, nil },
+ Retain: func(Proc, Rep, error) {},
+ }, nil
+ }}}}
+ return run, a.startAdmitted(t.Context(), &actEntry{host: obs.Host}, obs, policy)
+}
+
+// Five starts, then the loop gives up, with the delay between starts
+// doubling: 1, 2, 4 and 8 seconds. The loop's backoff is time.NewTimer
+// inline (claimwake.go), with no clock or sleeper to substitute, so this
+// reads real time and takes 15 s; -short skips it.
+func TestStartAdmittedLoopStopsAfterFiveStartsWithDoublingBackoff(t *testing.T) {
+ if testing.Short() {
+ t.Skip("takes 15 s of real backoff")
+ }
+ run, err := driveLoop(t, startPolicy{MaxStarts: 5, Window: 30 * time.Second, Deadline: time.Now().Add(time.Hour), Probe: true})
+ if !errors.Is(err, ErrClaimIsolationUnavailable) || errors.Is(err, ErrAttemptOver) {
+ t.Fatalf("the last start's refusal should end the loop: %v", err)
+ }
+ at := run.starts()
+ if len(at) != 5 || run.failed != 5 {
+ t.Fatalf("starts=%d failed=%d, want 5 and 5", len(at), run.failed)
+ }
+ for i, want := range []time.Duration{time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second} {
+ if gap := at[i+1].Sub(at[i]); gap < want || gap > want+400*time.Millisecond {
+ t.Errorf("delay before start %d = %v, want %v", i+2, gap, want)
+ }
+ }
+}
+
+// The cut deadline ends the attempt while it waits between starts: one
+// start, then ErrAttemptOver at the deadline, not after the 1 s backoff.
+func TestStartAdmittedLoopCutDeadlineEndsTheBackoffWait(t *testing.T) {
+ deadline := time.Now().Add(300 * time.Millisecond)
+ run, err := driveLoop(t, startPolicy{MaxStarts: 5, Window: 30 * time.Second, Deadline: deadline, Probe: true})
+ if !errors.Is(err, ErrAttemptOver) {
+ t.Fatalf("err=%v", err)
+ }
+ if n := len(run.starts()); n != 1 {
+ t.Fatalf("starts=%d, want 1", n)
+ }
+ if late := time.Since(deadline); late > 500*time.Millisecond {
+ t.Fatalf("the attempt ended %v after the deadline", late)
+ }
+}
+
+// A deadline already passed ends the attempt before any start: Reserve is
+// never reached.
+func TestStartAdmittedLoopNoStartPastTheCutDeadline(t *testing.T) {
+ run, err := driveLoop(t, startPolicy{MaxStarts: 5, Window: 30 * time.Second, Deadline: time.Now().Add(-time.Second), Probe: true})
+ if !errors.Is(err, ErrAttemptOver) || len(run.starts()) != 0 {
+ t.Fatalf("err=%v starts=%d", err, len(run.starts()))
+ }
+}
+
+func TestStartAdmittedLoopWindowAndReadinessAtTheBoundary(t *testing.T) {
+ t.Skip("blocked on claim Plan 9 Group D: RoleLaunch.Start (rolelaunch_linux.go) returns ErrClaimIsolationUnavailable " +
+ "unconditionally, so beforeStart/OpenProbe (which opens the 30 s window) and Ready never run through startAdmitted. " +
+ "When Group D replaces that refusal, drive: window expiry before a start (OpenProbe returns a time older than the window), " +
+ "readiness at opened+Window and at the cut deadline rejected with ErrAttemptOver, readiness at opened+Window-1s accepted.")
+}
diff --git a/internal/activator/claimwindow_test.go b/internal/activator/claimwindow_test.go
index 62fdf0471..760ae6613 100644
--- a/internal/activator/claimwindow_test.go
+++ b/internal/activator/claimwindow_test.go
@@ -88,3 +88,91 @@ func TestClaimProbeWindowJournalsFirstStartAndSurvivesRetry(t *testing.T) {
t.Fatalf("expired retry: %v", err)
}
}
+
+// Ported from the model's TestProbeWindow and wProbeWindowAt (R12-2,
+// INV-19, internal/sim/hostlife/witness_test.go): readiness k seconds after
+// the attempt's first start. Main's boundary is adopted (ruling H17): the
+// window and the cut deadline both fail at the instant, not after it
+// (claimwindow.go: !now.Before), so 29 is accepted, 30 and 31 are not.
+//
+// The loop around probeWindow is not reachable here. Its Ready hook runs
+// only after RoleLaunch.Start, which needs the isolated root, so the
+// readiness decision is tested where the loop reads it: probeWindow.start
+// before a start and probeWindow.result after readiness.
+func TestProbeWindowBoundaries(t *testing.T) {
+ t0 := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ far := t0.Add(time.Hour)
+ newWindow := func(deadline time.Time, now *time.Time) (*probeWindow, func(context.Context, time.Time) (time.Time, error), *int) {
+ var saved time.Time
+ calls := 0
+ journal := func(_ context.Context, proposed time.Time) (time.Time, error) {
+ calls++
+ if saved.IsZero() {
+ saved = proposed
+ }
+ return saved, nil
+ }
+ return &probeWindow{policy: startPolicy{MaxStarts: 5, Probe: true, Window: 30 * time.Second, Deadline: deadline}, now: func() time.Time { return *now }}, journal, &calls
+ }
+ for k, want := range map[int]bool{29: true, 30: false, 31: false} {
+ now := t0
+ b, journal, _ := newWindow(far, &now)
+ if err := b.start(t.Context(), journal); err != nil {
+ t.Fatalf("k=%d first start: %v", k, err)
+ }
+ now = t0.Add(time.Duration(k) * time.Second)
+ err := b.result(nil)
+ if (err == nil) != want || (err != nil && !errors.Is(err, ErrAttemptOver)) {
+ t.Fatalf("k=%d: readiness result %v, accepted want %v", k, err, want)
+ }
+ }
+ // The environment becomes ready only after the window: no further start
+ // is journaled, and the first start's time is not reset.
+ now := t0
+ b, journal, calls := newWindow(far, &now)
+ if err := b.start(t.Context(), journal); err != nil || *calls != 1 {
+ t.Fatalf("first start: %v (journal calls %d)", err, *calls)
+ }
+ now = t0.Add(30 * time.Second)
+ if err := b.start(t.Context(), journal); !errors.Is(err, ErrAttemptOver) || *calls != 1 {
+ t.Fatalf("start after the window: %v (journal calls %d)", err, *calls)
+ }
+ // The absolute cut deadline bounds the whole, inside the window.
+ now = t0
+ c, journal, _ := newWindow(t0.Add(4*time.Second), &now)
+ if err := c.start(t.Context(), journal); err != nil {
+ t.Fatal(err)
+ }
+ now = t0.Add(3 * time.Second)
+ if err := c.result(nil); err != nil {
+ t.Fatalf("readiness before the cut deadline: %v", err)
+ }
+ now = t0.Add(4 * time.Second)
+ if err := c.result(nil); !errors.Is(err, ErrAttemptOver) {
+ t.Fatalf("readiness at the cut deadline: %v", err)
+ }
+ now = t0.Add(5 * time.Second)
+ if err := c.start(t.Context(), journal); !errors.Is(err, ErrAttemptOver) {
+ t.Fatalf("start past the cut deadline: %v", err)
+ }
+ // A readiness already accepted is not revisited by a later clock.
+ now = t0
+ d, journal, _ := newWindow(far, &now)
+ if err := d.start(t.Context(), journal); err != nil {
+ t.Fatal(err)
+ }
+ d.ready = true
+ now = t0.Add(time.Hour)
+ if err := d.result(nil); err != nil {
+ t.Fatalf("accepted readiness revisited: %v", err)
+ }
+ // Five starts at most, whatever the clock says.
+ a := &Service{cfg: Config{HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box", Read: func(context.Context, string) (hostclaim.Observed, error) { return hostclaim.Observed{}, nil }, Prepare: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error) {
+ t.Fatal("six starts were allowed to prepare")
+ return ClaimStart{}, nil
+ }}}}
+ obs := hostclaim.Observed{Host: "x.example"}
+ if err := a.startAdmitted(t.Context(), &actEntry{host: obs.Host}, obs, startPolicy{MaxStarts: 6, Window: 30 * time.Second, Probe: true}); !errors.Is(err, hostwrite.ErrUnavailable) {
+ t.Fatalf("a sixth start: %v", err)
+ }
+}
diff --git a/internal/hostclaim/move.go b/internal/hostclaim/move.go
index 761b82618..c13d31df1 100644
--- a/internal/hostclaim/move.go
+++ b/internal/hostclaim/move.go
@@ -421,7 +421,7 @@ func queueLocalPark(c *Claim, opID, box string, newID func() string) {
// Claim states in which a region's whole location is retired (§6.4, §7.8):
// the landing after the commit, and a rollback from the target's release.
const (
- phaseLandingCommitted = "committed"
+ PhaseCommitted = "committed"
phaseRestoringReleased = "released"
phaseRestoringClaimed = "claimed"
)
@@ -441,7 +441,7 @@ const (
func requireRetiredRegion(c *Claim, region, box string) error {
to := c.Op.Intent["to"]
switch {
- case c.Op.Kind == KindLanding && c.Op.Phase == phaseLandingCommitted:
+ case c.Op.Kind == KindLanding && c.Op.Phase == PhaseCommitted:
if to != "" && region != to && (box == "" || box == c.Op.Intent["source"]) {
return nil
}
@@ -561,6 +561,59 @@ func CompleteLocalPark(opID, box string, earlierStamp uint64) func(*Claim) error
}
}
+// Commit is the claim half of the record's commit (§7.5, §7.9 step 1): the
+// landing goes to committed and, in the same write, the parks the commit owes
+// are queued through the Plan 2 helpers: the source region's whole location
+// (QueueLocationPark, which holds the lifecycle and exact-location checks),
+// the source box's park-local, and the target region's staging lane
+// (QueueStagingPark). The source's park-local is queued only when the source
+// box lists paths of the journalled source stamp S (InventoryHasStamp, H5b);
+// otherwise none is. CoverRegionPredecessors then covers the source region's
+// admission records. holder.stamp, session and generation are not touched
+// (R10-3, H3).
+//
+// Idempotent under the op: a landing already committed returns nil and queues
+// nothing. A refused commit leaves the claim as it found it.
+func Commit(opID, sourceRegion, sourceBox, targetRegion string, sourceScope, stagingScope ParkScope, newID func() string) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := RequireOp(opID)(c); err != nil {
+ return err
+ }
+ if c.Op.Kind != KindLanding {
+ return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind)
+ }
+ if c.Op.Phase == PhaseCommitted {
+ return nil
+ }
+ if c.Op.Phase != "probing" && c.Op.Phase != "probed" {
+ return fmt.Errorf("%w: phase %q", ErrWrongState, c.Op.Phase)
+ }
+ if len(c.Op.Participants) != 2 || c.Op.Exec != c.Op.Participants[1] {
+ return fmt.Errorf("%w: exec %q is not the target", ErrWrongState, c.Op.Exec)
+ }
+ if sourceBox != c.Op.Participants[0] {
+ return fmt.Errorf("%w: %q is not the move's source", ErrWrongState, sourceBox)
+ }
+ parkBox := ""
+ if stamp, err := strconv.ParseUint(c.Op.Journal[JournalSourceStamp], 10, 64); err == nil && InventoryHasStamp(c, sourceBox, stamp) {
+ parkBox = sourceBox
+ }
+ phase, obligations := c.Op.Phase, len(c.Obligations)
+ c.Op.Phase = PhaseCommitted
+ for _, step := range []func(*Claim) error{
+ QueueLocationPark(opID, sourceRegion, parkBox, sourceScope, newID),
+ QueueStagingPark(opID, targetRegion, stagingScope, newID),
+ CoverRegionPredecessors(opID, PhaseCommitted, sourceRegion),
+ } {
+ if err := step(c); err != nil {
+ c.Op.Phase, c.Obligations = phase, c.Obligations[:obligations]
+ return err
+ }
+ }
+ return nil
+ }
+}
+
// QueueResidueParks queues one park-generation per listed generation for a
// landing's earlier-stay residue in the target region: RootGeneration for
// the origin, the generation name for the rest, all scoped to the same
diff --git a/internal/hostclaim/move_test.go b/internal/hostclaim/move_test.go
index 19d375a39..20768fd83 100644
--- a/internal/hostclaim/move_test.go
+++ b/internal/hostclaim/move_test.go
@@ -1155,3 +1155,150 @@ func TestParkQueueHelpersPreserveTheRestOfTheClaim(t *testing.T) {
})
}
}
+
+// probedLanding drives the claim to landing/probed the way the landing does:
+// the flip, then every record phase mirrored through SetMovePhase. The
+// target's probe admission is on the holder, its predecessor in region ie.
+func probedLanding(t *testing.T) *Claim {
+ t.Helper()
+ c := movingToSealed(t)
+ c.Gen = 7
+ if err := Flip(moveA, "uk-box", 3, "gen-7")(c); err != nil {
+ t.Fatal(err)
+ }
+ for _, step := range [][2]string{{"checking", "parking"}, {"parking", "building"}, {"building", "published"}, {"published", "local"}, {"local", "configured"}, {"configured", "restored"}, {"restored", "probing"}, {"probing", "probed"}} {
+ if err := SetMovePhase(moveA, KindLanding, step[0], step[1])(c); err != nil {
+ t.Fatal(err)
+ }
+ }
+ c.Holder.Session = 9
+ c.Holder.Admissions = []AdmissionRecord{{Region: "uk", PredecessorRegion: "ie", Session: 9, Box: "uk-box", Generation: moveA, Predecessor: "gen-7", Mode: "cold", Coverage: "sealed"}}
+ return c
+}
+
+func commitScopes(c *Claim) (src, stg ParkScope) {
+ at := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)
+ src = ParkScope{Location: c.Holder.Location, Incarnation: c.Holder.Incarnation, EvidenceAt: at, RequestLimit: 120 * time.Second}
+ stg = ParkScope{Location: "moves/" + c.Host + "/" + moveA, Incarnation: c.Holder.Incarnation, EvidenceAt: at, RequestLimit: 120 * time.Second}
+ return src, stg
+}
+
+func obligationKinds(c *Claim) map[string]int {
+ kinds := map[string]int{}
+ for _, o := range c.Obligations {
+ kinds[o.Kind+"/"+o.Region+o.Box]++
+ }
+ return kinds
+}
+
+// wCommitCoverage, wCommitReplayed, H3, H5(b).
+func TestCommitQueuesObligationsAndCoversTheSourceRegion(t *testing.T) {
+ for _, from := range []string{"probing", "probed"} {
+ c := probedLanding(t)
+ c.Op.Phase = from
+ // The source box holds paths of the stay the move leaves: stamp S = 3.
+ c.Inventory = map[string]InventoryEntry{"ie-box": {Layout: "flat", Paths: map[string]uint64{"/data/a": 3}}}
+ stamp, session, gen := c.Holder.Stamp, c.Holder.Session, c.Holder.Generation
+ ids := 0
+ newID := func() string { ids++; return fmt.Sprintf("id%d", ids) }
+ src, stg := commitScopes(c)
+ if err := Commit(moveA, "ie", "ie-box", "uk", src, stg, newID)(c); err != nil {
+ t.Fatalf("from %s: %v", from, err)
+ }
+ if c.Op.Kind != KindLanding || c.Op.Phase != PhaseCommitted || c.Op.Exec != "uk-box" {
+ t.Fatalf("after commit: %+v", c.Op)
+ }
+ // H3: the holder's stamp, session and generation are the flip's, untouched.
+ if c.Holder.Stamp != stamp || c.Holder.Session != session || c.Holder.Generation != gen {
+ t.Fatalf("commit touched the holder: %+v", c.Holder)
+ }
+ k := obligationKinds(c)
+ if k["park-replica/ie"] != 1 || k["park-local/ie-box"] != 1 || k["park-replica/uk"] != 1 || len(c.Obligations) != 3 {
+ t.Fatalf("obligations: %+v", c.Obligations)
+ }
+ for _, o := range c.Obligations {
+ if o.Kind == "park-replica" && o.Region == "ie" && (o.Park == nil || o.Park.Location != c.Holder.Location || o.Owner != "region:ie") {
+ t.Fatalf("source park: %+v", o)
+ }
+ if o.Kind == "park-replica" && o.Region == "uk" && (o.Park == nil || o.Park.Location != stg.Location) {
+ t.Fatalf("staging park: %+v", o)
+ }
+ }
+ if !c.Holder.Admissions[0].Covered {
+ t.Fatal("the source-region predecessor is not covered (wCommitCoverage)")
+ }
+ if err := c.Validate(); err != nil {
+ t.Fatalf("the committed claim must validate: %v", err)
+ }
+ // Replay: nothing doubles and no ids are minted (wCommitReplayed).
+ before, usedIDs := mustJSON(t, c), ids
+ if err := Commit(moveA, "ie", "ie-box", "uk", src, stg, newID)(c); err != nil || ids != usedIDs || string(mustJSON(t, c)) != string(before) {
+ t.Fatalf("replay: %v ids %d->%d", err, usedIDs, ids)
+ }
+ }
+}
+
+// H5(b): the source's park-local is queued only when the source box lists
+// paths of the journalled source stamp S.
+func TestCommitQueuesTheSourceParkLocalOnlyForTheSourceStamp(t *testing.T) {
+ for name, inv := range map[string]map[string]InventoryEntry{
+ "no inventory": nil,
+ "other stamp only": {"ie-box": {Layout: "flat", Paths: map[string]uint64{"/data/a": 2}}},
+ "stamp on another box": {"uk-box": {Layout: "flat", Paths: map[string]uint64{"/data/a": 3}}},
+ } {
+ c := probedLanding(t)
+ c.Inventory = inv
+ ids := 0
+ newID := func() string { ids++; return fmt.Sprintf("id%d", ids) }
+ src, stg := commitScopes(c)
+ if err := Commit(moveA, "ie", "ie-box", "uk", src, stg, newID)(c); err != nil {
+ t.Fatalf("%s: %v", name, err)
+ }
+ k := obligationKinds(c)
+ if k["park-replica/ie"] != 1 || k["park-replica/uk"] != 1 || len(c.Obligations) != 2 {
+ t.Fatalf("%s: obligations %+v", name, c.Obligations)
+ }
+ if !c.Holder.Admissions[0].Covered {
+ t.Fatalf("%s: the source region is not covered", name)
+ }
+ }
+}
+
+// wCommitInterrupted: a commit that is refused leaves the claim as it was.
+func TestCommitRefusesAnythingButAProbingTargetLanding(t *testing.T) {
+ newID := func() string { return "x" }
+ type commitFn = func(c *Claim, src, stg ParkScope) func(*Claim) error
+ std := func(op, region, box string) commitFn {
+ return func(c *Claim, src, stg ParkScope) func(*Claim) error {
+ return Commit(op, region, box, "uk", src, stg, newID)
+ }
+ }
+ for name, tc := range map[string]struct {
+ mutate func(c *Claim, src, stg *ParkScope)
+ fn commitFn
+ want error
+ }{
+ "serving claim": {func(c *Claim, _, _ *ParkScope) { c.Op = Op{Kind: KindServing, Exec: "ie-box"} }, std(moveA, "ie", "ie-box"), ErrStaleOp},
+ "another op": {func(*Claim, *ParkScope, *ParkScope) {}, std(moveB, "ie", "ie-box"), ErrStaleOp},
+ "moving": {func(c *Claim, _, _ *ParkScope) { c.Op.Kind, c.Op.Phase, c.Op.Exec = KindMoving, PhaseSealed, "" }, std(moveA, "ie", "ie-box"), ErrWrongState},
+ "landing before probing": {func(c *Claim, _, _ *ParkScope) { c.Op.Phase = "restored" }, std(moveA, "ie", "ie-box"), ErrWrongState},
+ "exec not the target": {func(c *Claim, _, _ *ParkScope) { c.Op.Exec = "ie-box" }, std(moveA, "ie", "ie-box"), ErrWrongState},
+ "exec none": {func(c *Claim, _, _ *ParkScope) { c.Op.Exec = "" }, std(moveA, "ie", "ie-box"), ErrWrongState},
+ "source box is not the source": {func(*Claim, *ParkScope, *ParkScope) {}, std(moveA, "ie", "uk-box"), ErrWrongState},
+ "source region is the target": {func(*Claim, *ParkScope, *ParkScope) {}, std(moveA, "uk", "ie-box"), ErrWrongState},
+ "source park is not the holder's location": {func(_ *Claim, s, _ *ParkScope) { s.Location = "instances/a.go.example.com/other" }, std(moveA, "ie", "ie-box"), ErrMalformed},
+ "staging park is another lane": {func(c *Claim, _, g *ParkScope) { g.Location = "moves/" + c.Host + "/" + moveB }, std(moveA, "ie", "ie-box"), ErrMalformed},
+ } {
+ c := probedLanding(t)
+ c.Inventory = map[string]InventoryEntry{"ie-box": {Layout: "flat", Paths: map[string]uint64{"/data/a": 3}}}
+ src, stg := commitScopes(c)
+ tc.mutate(c, &src, &stg)
+ before := mustJSON(t, c)
+ if err := tc.fn(c, src, stg)(c); !errors.Is(err, tc.want) {
+ t.Fatalf("%s: got %v, want %v", name, err, tc.want)
+ }
+ if after := mustJSON(t, c); string(after) != string(before) {
+ t.Fatalf("%s mutated the claim:\nbefore %s\nafter %s", name, before, after)
+ }
+ }
+}
diff --git a/internal/moves/probe.go b/internal/moves/probe.go
new file mode 100644
index 000000000..a4e9bd3d9
--- /dev/null
+++ b/internal/moves/probe.go
@@ -0,0 +1,38 @@
+package moves
+
+// The probe's attempt policy (moves spec §7.7, R12-2, INV-19).
+//
+// The window itself is not restated here. The activator's probeWindow and
+// start loop (internal/activator/claimwindow.go, claimwake.go) already
+// enforce the 30 second window counted from the attempt's first start, the
+// absolute cut deadline before every start and before readiness is
+// accepted, at most five starts and the doubling backoff between them. This
+// file holds the one piece of policy that lives with the moves: how many
+// attempts a probe is given. The per-attempt cost that prices them is
+// budget.go's, and ProbeAttemptsMax is its ceiling.
+
+// RecoveryAttempts is the number of probe attempts a recovery install is
+// given (§7.7): three rounds of the landing's three, because a recovery has
+// nowhere further to fall back to.
+const RecoveryAttempts = 9
+
+// Probe kinds, as the operation kinds the claim records.
+const (
+ ProbeLanding = "landing"
+ ProbeRecovery = "recovery"
+)
+
+// AttemptsFor is how many probe attempts a probe of the given kind may
+// make. A landing gets the attempts T11 admitted (budget.go
+// AttemptsAdmitted), at most ProbeAttemptsMax, and none when none were
+// admitted. A recovery gets RecoveryAttempts whatever was admitted. A kind
+// that is neither gets none.
+func AttemptsFor(kind string, admitted int) int {
+ switch kind {
+ case ProbeLanding:
+ return max(0, min(ProbeAttemptsMax, admitted))
+ case ProbeRecovery:
+ return RecoveryAttempts
+ }
+ return 0
+}
diff --git a/internal/moves/probe_test.go b/internal/moves/probe_test.go
new file mode 100644
index 000000000..83c15b68e
--- /dev/null
+++ b/internal/moves/probe_test.go
@@ -0,0 +1,30 @@
+package moves
+
+import "testing"
+
+func TestAttemptsFor(t *testing.T) {
+ for _, c := range []struct {
+ kind string
+ admitted int
+ want int
+ }{
+ {"landing", 2, 2},
+ {"landing", 7, 3},
+ {"landing", 3, 3},
+ {"landing", 1, 1},
+ {"landing", 0, 0},
+ {"landing", -4, 0},
+ {"recovery", 0, 9},
+ {"recovery", 2, 9},
+ {"recovery", 7, 9},
+ {"serving", 3, 0},
+ {"", 3, 0},
+ } {
+ if got := AttemptsFor(c.kind, c.admitted); got != c.want {
+ t.Errorf("AttemptsFor(%q, %d) = %d, want %d", c.kind, c.admitted, got, c.want)
+ }
+ }
+ if ProbeAttemptsMax != 3 || RecoveryAttempts != 9 {
+ t.Fatal("the attempt ceilings are the spec's: 3 for a landing, 9 for a recovery")
+ }
+}
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/