carlos / platform Public

moves: park scope helpers, one source for every moves chore's scope (claim rev 6.23 §2, INV-22, ruling K8)

SourceLocationScope, StagingScope, AbortScope and ReturnLaneScope take
their genuine bound as an argument and never read the clock; each sets
the 120 s request limit. The four box callers (sourceLocationParkScope,
revertClaim, rollbackRun.remote, recoverRun.probed) use them; behaviour
is unchanged. No BindScope: every chore is bound at its queuing CAS and
park.Engine.Bind refuses a rebind; TestParkScopeSurvivesRestart is the
witness wParkScopeSurvivesRestart against both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 7deabe4d30685d2f0b8653448643564bd9e1ba28 parent 0db2980
9 files changed, +225 −9
  • approved_moves_copy_test.go +1 −0
  • internal/host/movescut.go +2 −2
  • internal/host/movesfinal.go +1 −2
  • internal/host/movesrecover.go +1 −2
  • internal/host/movesrollback.go +1 −2
  • internal/hostclaim/move.go +4 −1
  • internal/moves/parkscope.go +55 −0
  • internal/moves/parkscope_test.go +54 −0
  • internal/park/scope_restart_test.go +106 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 1a34e65b4..321ed1234 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -630,6 +630,7 @@ var movesScanFiles = []string{
"internal/moves/journal.go",
"internal/moves/lease.go",
"internal/moves/ltxbuild.go",
+ "internal/moves/parkscope.go",
"internal/moves/probe.go",
"internal/moves/readiness.go",
"internal/moves/returnlane.go",
diff --git a/internal/host/movescut.go b/internal/host/movescut.go
index fbf4d0cb8..26306c03f 100644
--- a/internal/host/movescut.go
+++ b/internal/host/movescut.go
@@ -52,7 +52,7 @@ const cutLandingWindow = 15 * time.Minute
// cutRequestLimit is the staging park's request limit, the longest a
// staging request runs (lease's stagingRequestTimeout).
-const cutRequestLimit = 120 * time.Second
+const cutRequestLimit = moves.ParkRequestLimit
// cutMaxDeferred is how many final rounds may defer before the cut reverts
// (§7.4 staged: three deferred rounds).
@@ -1224,7 +1224,7 @@ func (w *movesWorker) revertClaim(ctx context.Context, s release.Store, rec prov
return err
}
}
- scope := hostclaim.ParkScope{Location: moves.ScopeLocation(rec.Host, id), Incarnation: obs.Claim.Holder.Incarnation, EvidenceAt: bound, RequestLimit: cutRequestLimit}
+ scope := moves.StagingScope(rec.Host, id, obs.Claim.Holder.Incarnation, bound)
if _, err := hostclaim.Update(ctx, s, obs, hostclaim.Revert(id, cfg.Box, rec.Move.To, &scope, obligationID)); err != nil {
return fmt.Errorf("revert the claim: %w", err)
}
diff --git a/internal/host/movesfinal.go b/internal/host/movesfinal.go
index a339f7694..b65ae5a78 100644
--- a/internal/host/movesfinal.go
+++ b/internal/host/movesfinal.go
@@ -174,8 +174,7 @@ func sourceLocationParkScope(rec provision.Record, holder hostclaim.Holder) (hos
if rec.Move == nil || rec.Move.FlippedAt == nil {
return hostclaim.ParkScope{}, errors.New("the source's location park needs the flip's time")
}
- return hostclaim.ParkScope{Location: strings.TrimSuffix(holder.Location, "/"), Incarnation: holder.Incarnation,
- EvidenceAt: rec.Move.FlippedAt.UTC(), RequestLimit: cutRequestLimit}, nil
+ return moves.SourceLocationScope(holder, *rec.Move.FlippedAt), nil
}
// errFinalChanged is the record or claim no longer being this move's commit
diff --git a/internal/host/movesrecover.go b/internal/host/movesrecover.go
index 67793c013..a2764a6fa 100644
--- a/internal/host/movesrecover.go
+++ b/internal/host/movesrecover.go
@@ -654,8 +654,7 @@ func (r *recoverRun) probed(ctx context.Context) (bool, error) {
if err != nil {
return false, err
}
- scope := hostclaim.ParkScope{Location: moves.ScopeLocation(host, r.rb), Incarnation: obs.Claim.Holder.Incarnation,
- EvidenceAt: seal.Bound().UTC(), RequestLimit: cutRequestLimit}
+ scope := moves.ReturnLaneScope(host, r.rb, obs.Claim.Holder.Incarnation, seal.Bound())
lane = hostclaim.QueueReturnLanePark(id, cfg.Region, scope, obligationID)
}
switch obs.Claim.Op.Phase {
diff --git a/internal/host/movesrollback.go b/internal/host/movesrollback.go
index e35a74372..7a0b1734a 100644
--- a/internal/host/movesrollback.go
+++ b/internal/host/movesrollback.go
@@ -1119,8 +1119,7 @@ func (r *rollbackRun) remote(ctx context.Context, obs hostclaim.Observed, j *mov
if err != nil {
return false, err
}
- scope := hostclaim.ParkScope{Location: moves.ScopeLocation(host, id), Incarnation: obs.Claim.Holder.Incarnation,
- EvidenceAt: staged.Bound().UTC(), RequestLimit: cutRequestLimit}
+ scope := moves.StagingScope(host, id, obs.Claim.Holder.Incarnation, staged.Bound())
if _, err := hostclaim.Update(ctx, r.s, obs, hostclaim.QueueStagingPark(id, cfg.Region, scope, obligationID)); err != nil {
return false, fmt.Errorf("queue the move's lane park: %w", err)
}
diff --git a/internal/hostclaim/move.go b/internal/hostclaim/move.go
index 4215bfcec..ad0bfbf61 100644
--- a/internal/hostclaim/move.go
+++ b/internal/hostclaim/move.go
@@ -368,7 +368,10 @@ func sameParkScope(a, b ParkScope) bool {
//
// Each helper changes obligations only, is idempotent per its key (a replay
// allocates no ids), and refuses a replay that names the same chore with a
-// different scope rather than overwriting it.
+// different scope rather than overwriting it. The scopes themselves come
+// from internal/moves (SourceLocationScope, StagingScope, AbortScope,
+// ReturnLaneScope), the one source for the box and the console; moves
+// imports hostclaim, so they cannot be called from here.
// parkQueueGuard is the check every helper shares: the op is current, the
// claim has its holder, the scope is valid for this host and names this
diff --git a/internal/moves/parkscope.go b/internal/moves/parkscope.go
new file mode 100644
index 000000000..daedeac6d
--- /dev/null
+++ b/internal/moves/parkscope.go
@@ -0,0 +1,55 @@
+package moves
+
+import (
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+)
+
+// The park scopes a move's chores carry (claim rev 6.23 §2, INV-22; Plan 5
+// ruling K8). These helpers are the one place the box and the console build
+// them. Each takes its genuine bound as an argument and never reads the
+// clock: the scope is bound once, in the CAS that queues the chore, and a
+// retry or a restart that computes a later bound is refused
+// (hostclaim.QueueStagingPark and friends, park.Engine.Bind), so the
+// horizon is the evidence's, never the cleanup's start. The bound is stored
+// in UTC.
+
+// ParkRequestLimit is every moves park's request limit: the longest a
+// staging request runs (lease's stagingRequestTimeout).
+const ParkRequestLimit = 120 * time.Second
+
+// SourceLocationScope is the source region's location park a commit queues:
+// holder.location, in the holder's form without a trailing slash, and the
+// holder's incarnation. Its horizon starts at the flip (ruling H7): the flip
+// comes after ExecToNone, after which the source never writes its location
+// again.
+func SourceLocationScope(holder hostclaim.Holder, flippedAt time.Time) hostclaim.ParkScope {
+ return hostclaim.ParkScope{Location: strings.TrimSuffix(holder.Location, "/"), Incarnation: holder.Incarnation, EvidenceAt: flippedAt.UTC(), RequestLimit: ParkRequestLimit}
+}
+
+// StagingScope is the park of a move's own staging lane, moves/<host>/<op>,
+// after a seal: its horizon is the seal's bound (a staged seal's for a
+// rollback's target, the journalled revert bound for a revert).
+func StagingScope(host, op, inc string, sealBound time.Time) hostclaim.ParkScope {
+ return laneScope(host, op, inc, sealBound)
+}
+
+// AbortScope is the park of an aborted move's staging lane. No seal exists
+// before an abort, so its horizon is the journalled abort bound (Plan 5
+// ruling K5): the later of the decision's time plus a signature's life and
+// the skew, and any seal's bound.
+func AbortScope(host, op, inc string, abortBound time.Time) hostclaim.ParkScope {
+ return laneScope(host, op, inc, abortBound)
+}
+
+// ReturnLaneScope is the park of a dirty rollback's return lane,
+// moves/<host>/<rb>: its horizon is the return seal's bound.
+func ReturnLaneScope(host, rbID, inc string, returnSealBound time.Time) hostclaim.ParkScope {
+ return laneScope(host, rbID, inc, returnSealBound)
+}
+
+func laneScope(host, lane, inc string, bound time.Time) hostclaim.ParkScope {
+ return hostclaim.ParkScope{Location: ScopeLocation(host, lane), Incarnation: inc, EvidenceAt: bound.UTC(), RequestLimit: ParkRequestLimit}
+}
diff --git a/internal/moves/parkscope_test.go b/internal/moves/parkscope_test.go
new file mode 100644
index 000000000..c20bef500
--- /dev/null
+++ b/internal/moves/parkscope_test.go
@@ -0,0 +1,54 @@
+package moves
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+)
+
+// Every scope helper's EvidenceAt is the bound it was handed, never the
+// clock (claim rev 6.23 §2, INV-22): a bound years from now and one years
+// ago, in a zone that is not UTC, both come back as the same instant in UTC.
+// Each scope is a valid park for the host, names the incarnation it was
+// given, and carries the staging request limit.
+func TestScopeHelpersUseTheGenuineBound(t *testing.T) {
+ const host = "shop.bab.example.com"
+ inc := strings.Repeat("ab", 32)
+ zone := time.FixedZone("plus-one", 3600)
+ for _, bound := range []time.Time{
+ time.Date(2031, 3, 4, 5, 6, 7, 8, zone),
+ time.Date(2001, 9, 10, 11, 12, 13, 14, zone),
+ } {
+ for _, c := range []struct {
+ name string
+ got hostclaim.ParkScope
+ location string
+ }{
+ {"source location, holder with a slash", SourceLocationScope(hostclaim.Holder{Location: "instances/" + host + "/" + inc + "/", Incarnation: inc}, bound), "instances/" + host + "/" + inc},
+ {"source location, holder without one", SourceLocationScope(hostclaim.Holder{Location: "instances/" + host, Incarnation: inc}, bound), "instances/" + host},
+ {"staging", StagingScope(host, "move-1", inc, bound), "moves/" + host + "/move-1"},
+ {"abort", AbortScope(host, "move-1", inc, bound), "moves/" + host + "/move-1"},
+ {"return lane", ReturnLaneScope(host, "rb-1", inc, bound), "moves/" + host + "/rb-1"},
+ } {
+ t.Run(c.name+"/"+bound.Format("2006"), func(t *testing.T) {
+ if !c.got.EvidenceAt.Equal(bound) || c.got.EvidenceAt.Location() != time.UTC {
+ t.Fatalf("EvidenceAt = %v, want %v in UTC", c.got.EvidenceAt, bound)
+ }
+ if c.got.RequestLimit != 120*time.Second {
+ t.Fatalf("RequestLimit = %v, want 120s", c.got.RequestLimit)
+ }
+ if c.got.Location != c.location || c.got.Incarnation != inc {
+ t.Fatalf("scope = %q/%q, want %q/%q", c.got.Location, c.got.Incarnation, c.location, inc)
+ }
+ if err := c.got.Validate(host); err != nil {
+ t.Fatalf("Validate(%q): %v", host, err)
+ }
+ })
+ }
+ }
+ if zero := StagingScope(host, "move-1", inc, time.Time{}); zero.Validate(host) == nil {
+ t.Fatal("a zero bound made a valid scope; the helper must not fill one in")
+ }
+}
diff --git a/internal/park/scope_restart_test.go b/internal/park/scope_restart_test.go
new file mode 100644
index 000000000..f8d39e654
--- /dev/null
+++ b/internal/park/scope_restart_test.go
@@ -0,0 +1,106 @@
+package park
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/moves"
+)
+
+// TestParkScopeSurvivesRestart is witness wParkScopeSurvivesRestart (claim
+// rev 6.23 §2, INV-22, Plan 5 ruling K8). A moves chore is queued with its
+// scope bound in the queuing CAS. After a restart the caller computes its
+// bound again, and the new one is later; neither a second queue nor a
+// park.Engine.Bind with it moves the scope: both are refused, the claim is
+// not written, and the first scope stands. A fresh engine then takes its
+// deadline from the persisted scope: pending before it, acknowledged after.
+func TestParkScopeSurvivesRestart(t *testing.T) {
+ f := newFixture(t)
+ ctx := t.Context()
+ obs, err := hostclaim.Read(ctx, f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ inc := obs.Claim.Holder.Incarnation
+ sealBound := f.ob.Park.EvidenceAt
+ first := moves.StagingScope(f.w.Host, f.w.Op, inc, sealBound)
+ obs, err = hostclaim.Update(ctx, f.e.Claims, obs, func(c *hostclaim.Claim) error {
+ c.Obligations = nil
+ return hostclaim.QueueStagingPark(f.w.Op, "ie", first, f.e.NewID)(c)
+ })
+ if err != nil {
+ t.Fatalf("queue the lane park: %v", err)
+ }
+ if len(obs.Claim.Obligations) != 1 {
+ t.Fatalf("obligations = %+v, want the lane park alone", obs.Claim.Obligations)
+ }
+ lane := obs.Claim.Obligations[0]
+ w := Work{Host: f.w.Host, ID: lane.ID, Op: lane.Op}
+
+ // The restart: the bound is computed again, a minute later.
+ later := moves.StagingScope(f.w.Host, f.w.Op, inc, sealBound.Add(time.Minute))
+ stands := func(when string) {
+ t.Helper()
+ now, err := hostclaim.Read(ctx, f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if now.ETag != obs.ETag {
+ t.Fatalf("%s: the claim was written", when)
+ }
+ if len(now.Claim.Obligations) != 1 || now.Claim.Obligations[0].Park == nil || !now.Claim.Obligations[0].Park.EvidenceAt.Equal(sealBound) {
+ t.Fatalf("%s: the first scope does not stand: %+v", when, now.Claim.Obligations)
+ }
+ }
+ if _, err := hostclaim.Update(ctx, f.e.Claims, obs, hostclaim.QueueStagingPark(f.w.Op, "ie", later, f.e.NewID)); !errors.Is(err, hostclaim.ErrWrongState) {
+ t.Fatalf("a second queue with a later bound: %v, want ErrWrongState", err)
+ }
+ stands("after the second queue")
+ if err := f.e.Bind(ctx, w, later); !errors.Is(err, ErrChanged) {
+ t.Fatalf("a bind with a later bound: %v, want ErrChanged", err)
+ }
+ stands("after the bind")
+ if err := f.e.Bind(ctx, w, first); err != nil {
+ t.Fatalf("a bind with the persisted scope: %v", err)
+ }
+ stands("after the same bind")
+
+ // A fresh engine (the agent restarted) reads its deadline from the claim.
+ restarted := f.e
+ restarted.View = func(_ context.Context, o hostclaim.Observed) (View, error) {
+ return View{ClaimETag: o.ETag, Region: "ie", AuthoritativeRegion: "ie", MarkerKnown: true, MarkerGeneration: "unrelated", GenerationOf: func(Ref) (string, bool) { return "live", true }}, nil
+ }
+ f.b.Put("data", first.Location+"/replica/0000/data", []byte("staged"))
+ deadline := sealBound.Add(SignatureAge + first.RequestLimit)
+ f.now = deadline.Add(-time.Second)
+ restarted.After = func(step string) error {
+ if step == "verify" {
+ f.now = deadline.Add(time.Hour)
+ }
+ return nil
+ }
+ if err := restarted.Run(ctx, w); !errors.Is(err, ErrPending) {
+ t.Fatalf("a run whose first listing is before the persisted deadline: %v, want ErrPending", err)
+ }
+ after, err := hostclaim.Read(ctx, f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(after.Claim.Obligations) != 1 || !after.Claim.Obligations[0].Park.EvidenceAt.Equal(sealBound) {
+ t.Fatalf("the run moved the scope: %+v", after.Claim.Obligations)
+ }
+ restarted.After = nil
+ if err := restarted.Run(ctx, w); err != nil {
+ t.Fatalf("a run after the persisted deadline: %v", err)
+ }
+ after, err = hostclaim.Read(ctx, f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(after.Claim.Obligations) != 0 || !after.Claim.QuietAfter["ie"].Equal(deadline) {
+ t.Fatalf("after the deadline: obligations %+v, quiet_after %v, want none and %v", after.Claim.Obligations, after.Claim.QuietAfter["ie"], deadline)
+ }
+}