carlos / platform Public

hostbox: Policy.Check takes a rollback's hold owner from the claim's intent (Task 3b fix round 1)

Under restoring, in the source's recovery phases (claimed, probing, probed,
opened), the registry hold must be the claim's intent.rollback, a valid
rollback id; otherwise no hold matches, and there is no fallback to the op.
Landing, moving and reverting keep the selection's op. End-to-end host test
through Policy.Check and MovesClaimPolicy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev ea1649ca3241ea33ddbfc233bfda20c4faccb8b9 parent 8a748ea
3 files changed, +155 −2
  • internal/host/movespolicy_test.go +39 −0
  • internal/hostbox/wakepolicy.go +32 −2
  • internal/hostbox/wakepolicy_test.go +84 −0
diff --git a/internal/host/movespolicy_test.go b/internal/host/movespolicy_test.go
index 39982b4e9..a1fe4a571 100644
--- a/internal/host/movespolicy_test.go
+++ b/internal/host/movespolicy_test.go
@@ -1193,3 +1193,42 @@ func TestMovesClaimPolicyBindsRecoveryInputs(t *testing.T) {
rpRefused(t, moves(t.Context(), c, mpSelection(c, "held-config"), cfgOf(recovered)), "the config is not the pinned one")
})
}
+
+// End to end (Task 3b fix round 1): the source's return writes through
+// hostbox.Policy.Check with its row held by the rollback id, as the J3
+// hand-over leaves it, and MovesClaimPolicy as Policy.Moves. Policy.Check
+// takes the hold's owner from the claim's intent.rollback; the move id's
+// hold, another rollback's, the target asking and the wrong phase refuse.
+func TestMovesClaimPolicyRestoringThroughPolicyCheck(t *testing.T) {
+ through := func(hold string, rec *provision.Record, c *hostclaim.Claim, s hostbox.Selection, id hostwrite.Identity) error {
+ p := hostbox.Policy{Moves: MovesClaimPolicy((&mpReader{rec: rec}).read), Held: func(context.Context, string) (string, error) { return hold, nil }}
+ return p.Check(t.Context(), c, s, id)
+ }
+ for phase, action := range map[string]string{
+ provision.RbSourceLocal: "held-timers",
+ provision.RbSourceConfigured: "held-config",
+ provision.RbSourceRestored: "install",
+ provision.RbSourceProbing: "spawn",
+ } {
+ c := rpRestoringClaim(rpClaimPhase(phase))
+ s := rpSelection(c, action)
+ if err := through(rpRB, rpRestoringRecord(phase), c, s, rpIdentity(action)); err != nil {
+ t.Errorf("%s/%s held by the rollback: %v", phase, action, err)
+ }
+ for _, hold := range []string{mpOp, "rb_zzzzzzzzzzzzzzzzzzzzzzzzzz"} {
+ if err := through(hold, rpRestoringRecord(phase), c, s, rpIdentity(action)); !errors.Is(err, hostbox.ErrChanged) {
+ t.Errorf("%s/%s held by %s: %v", phase, action, hold, err)
+ }
+ }
+ }
+ c := rpRestoringClaim(hostclaim.PhaseClaimed)
+ s := rpSelection(c, "held-timers")
+ s.Box, s.Region = mpTarget, "uk"
+ if err := through(rpRB, rpRestoringRecord(provision.RbSourceLocal), c, s, hostwrite.Identity{}); err == nil {
+ t.Fatal("the target's timers granted under the rollback's hold")
+ }
+ s = rpSelection(c, "held-timers")
+ if err := through(rpRB, rpRestoringRecord(provision.RbSourceConfigured), c, s, hostwrite.Identity{}); !errors.Is(err, ErrMovesRefused) {
+ t.Fatalf("held-timers at configured: %v", err)
+ }
+}
diff --git a/internal/hostbox/wakepolicy.go b/internal/hostbox/wakepolicy.go
index 9c3c00d65..51f7c0a0b 100644
--- a/internal/hostbox/wakepolicy.go
+++ b/internal/hostbox/wakepolicy.go
@@ -6,6 +6,7 @@ import (
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/provision"
)
// WakePhase is the K6 execution table. Participant membership alone never starts code.
@@ -68,14 +69,15 @@ func (p Policy) Check(ctx context.Context, c *hostclaim.Claim, s Selection, id h
if p.Moves == nil || s.Op == "" || c.Op.ID != s.Op || c.Op.Exec != s.Box {
return ErrChanged
}
+ owner, owned := movesHoldOwner(c, s)
switch s.Action {
case "held-config", "held-release", "held-timers":
- if hold != s.Op {
+ if !owned || hold != owner {
return ErrChanged
}
case "restore", "spawn", "seed", "writer", "install":
if hold != "" {
- if hold != s.Op {
+ if !owned || hold != owner {
return ErrChanged
}
} else if err := WakePhase(c, s.Box, false); err != nil {
@@ -87,4 +89,32 @@ func (p Policy) Check(ctx context.Context, c *hostclaim.Claim, s Selection, id h
return p.Moves(ctx, c, s, id)
}
+// movesHoldOwner is the op a moves selection's registry hold must name, and
+// false when no hold can be the selection's. A landing, a moving op and a
+// revert hold the row by their op id, the selection's. A rollback keeps the
+// move id as the claim's op id (hostclaim.RollBack) and hands the row to the
+// rollback id (ruling J3), so a restoring op in the source's recovery phases
+// expects the rollback id. That id comes only from the claim's
+// intent.rollback, as RollBack recorded it, never from the selection, the
+// request or a registry field, and it must be a valid rollback id. A
+// restoring op without one, or outside those phases, matches no hold and
+// never falls back to the selection's op (Plan 4 Task 3b fix round 1).
+func movesHoldOwner(c *hostclaim.Claim, s Selection) (string, bool) {
+ if c.Op.Kind != hostclaim.KindRestoring {
+ return s.Op, true
+ }
+ rb := c.Op.Intent["rollback"]
+ if !restoringHoldPhases[c.Op.Phase] || !provision.ValidRollbackID(rb) {
+ return "", false
+ }
+ return rb, true
+}
+
+// restoringHoldPhases are the source's recovery phases of a rollback
+// (hostclaim.RecoveryPhaseOrder(KindRestoring) from claimed on): the source
+// executes, and its registry row is held by the rollback id.
+var restoringHoldPhases = map[string]bool{
+ hostclaim.PhaseClaimed: true, hostclaim.PhaseProbing: true, hostclaim.PhaseProbed: true, hostclaim.PhaseOpened: true,
+}
+
const RequestLimit = 120 * time.Second
diff --git a/internal/hostbox/wakepolicy_test.go b/internal/hostbox/wakepolicy_test.go
index f7dd9add5..b655bfc3b 100644
--- a/internal/hostbox/wakepolicy_test.go
+++ b/internal/hostbox/wakepolicy_test.go
@@ -97,3 +97,87 @@ func TestPolicyOrdinaryWakeAfterReleaseHold(t *testing.T) {
}
}
}
+
+// A rollback keeps the move id as the claim's op id (hostclaim.RollBack) and
+// hands the registry row to the rollback id (ruling J3), so under restoring,
+// in the source's recovery phases, the hold Policy.Check expects is the
+// claim's intent.rollback, a valid rollback id (Plan 4 Task 3b fix round 1).
+// Landing, moving and reverting keep the selection's op. A restoring op with
+// no valid rollback id, or outside those phases, matches no hold and never
+// falls back to the op.
+func TestPolicyHoldOwnerUnderRollback(t *testing.T) {
+ const mv, rb, otherRB = "mv_move", "rb_abcdefghijklmnopqrstuvwxyz", "rb_zzzzzzzzzzzzzzzzzzzzzzzzzz"
+ check := func(kind, phase, hold string, intent map[string]string, action string) error {
+ c := &hostclaim.Claim{Holder: &hostclaim.Holder{}, Op: hostclaim.Op{Kind: kind, Phase: phase, ID: mv, Exec: "box", Intent: intent}}
+ p := Policy{Held: func(context.Context, string) (string, error) { return hold, nil }, Moves: func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error { return nil }}
+ return p.Check(t.Context(), c, Selection{Host: "x.example", Box: "box", Op: mv, Action: action}, hostwrite.Identity{})
+ }
+ unheldWake := map[string]bool{"restore": true, "spawn": true, "seed": true, "writer": true, "install": true}
+ restoring := map[string]string{"rollback": rb, "to": "uk"}
+ actions := []string{"held-config", "held-release", "held-timers", "restore", "spawn", "seed", "writer", "install"}
+ sourcePhases := []string{hostclaim.PhaseClaimed, hostclaim.PhaseProbing, hostclaim.PhaseProbed, hostclaim.PhaseOpened}
+ for _, action := range actions {
+ for _, phase := range sourcePhases {
+ if err := check(hostclaim.KindRestoring, phase, rb, restoring, action); err != nil {
+ t.Errorf("restoring/%s, held by its rollback, %s: %v", phase, action, err)
+ }
+ for name, hold := range map[string]string{"the move id": mv, "another rollback": otherRB, "an unrelated op": "op"} {
+ if err := check(hostclaim.KindRestoring, phase, hold, restoring, action); !errors.Is(err, ErrChanged) {
+ t.Errorf("restoring/%s, held by %s, %s: %v", phase, name, action, err)
+ }
+ }
+ }
+ for name, intent := range map[string]map[string]string{
+ "no intent": {},
+ "no rollback": {"to": "uk"},
+ "an empty rollback": {"rollback": ""},
+ "the move id as rollback": {"rollback": mv},
+ "a short rollback id": {"rollback": "rb_abc"},
+ "an upper-case rollback": {"rollback": "RB_ABCDEFGHIJKLMNOPQRSTUVWXYZ"},
+ "a non-base32 rollback id": {"rollback": "rb_abcdefghijklmnopqrstuvwx01"},
+ "a padded rollback id": {"rollback": rb + " "},
+ } {
+ for _, hold := range []string{mv, rb, intent["rollback"], ""} {
+ if hold == "" && unheldWake[action] {
+ continue // unheld: WakePhase decides, unchanged
+ }
+ if err := check(hostclaim.KindRestoring, hostclaim.PhaseClaimed, hold, intent, action); !errors.Is(err, ErrChanged) {
+ t.Errorf("restoring with %s, held by %q, %s: %v", name, hold, action, err)
+ }
+ }
+ }
+ for _, phase := range []string{hostclaim.PhaseReleasing, hostclaim.PhaseReleased, "", hostclaim.PhaseSealed, hostclaim.PhaseCommitted} {
+ for _, hold := range []string{rb, mv} {
+ if err := check(hostclaim.KindRestoring, phase, hold, restoring, action); !errors.Is(err, ErrChanged) {
+ t.Errorf("restoring outside the source's recovery phases (%q), held by %q, %s: %v", phase, hold, action, err)
+ }
+ }
+ }
+ for _, kind := range []string{hostclaim.KindLanding, hostclaim.KindMoving, hostclaim.KindReverting} {
+ if err := check(kind, hostclaim.PhaseProbing, rb, restoring, action); !errors.Is(err, ErrChanged) {
+ t.Errorf("%s held by a rollback id, %s: %v", kind, action, err)
+ }
+ if err := check(kind, hostclaim.PhaseProbing, mv, restoring, action); err != nil {
+ t.Errorf("%s held by its op, %s: %v", kind, action, err)
+ }
+ }
+ }
+ // The rollback id comes from the claim alone: a selection naming the
+ // rollback id as its op does not match the claim's op.
+ c := &hostclaim.Claim{Holder: &hostclaim.Holder{}, Op: hostclaim.Op{Kind: hostclaim.KindRestoring, Phase: hostclaim.PhaseClaimed, ID: mv, Exec: "box", Intent: restoring}}
+ p := Policy{Held: func(context.Context, string) (string, error) { return rb, nil }, Moves: func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error { return nil }}
+ if err := p.Check(t.Context(), c, Selection{Host: "x.example", Box: "box", Op: rb, Action: "held-timers"}, hostwrite.Identity{}); !errors.Is(err, ErrChanged) {
+ t.Fatalf("a selection naming the rollback: %v", err)
+ }
+ // The source's recovery phases are hostclaim's restoring order from
+ // claimed on.
+ order := hostclaim.RecoveryPhaseOrder(hostclaim.KindRestoring)
+ if len(order) != 6 || order[2] != hostclaim.PhaseClaimed || len(restoringHoldPhases) != 4 {
+ t.Fatalf("restoring order %v, hold phases %v", order, restoringHoldPhases)
+ }
+ for _, phase := range order[2:] {
+ if !restoringHoldPhases[phase] {
+ t.Fatalf("%s is not a hold phase", phase)
+ }
+ }
+}