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>
9 files changed,
+225
−9
approved_moves_copy_test.go+1 −0internal/host/movescut.go+2 −2internal/host/movesfinal.go+1 −2internal/host/movesrecover.go+1 −2internal/host/movesrollback.go+1 −2internal/hostclaim/move.go+4 −1internal/moves/parkscope.go+55 −0internal/moves/parkscope_test.go+54 −0internal/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) |
| + } |
| +} |