carlos / platform Public

host: the release materialization before every recovery probe, held-release at probing (Task 3b fix round 2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev efef6769ac201112ba4152b724a558fa4519bc6a parent ea1649c
2 files changed, +115 −19
  • internal/host/movespolicy.go +11 −9
  • internal/host/movespolicy_test.go +104 −10
diff --git a/internal/host/movespolicy.go b/internal/host/movespolicy.go
index 3de593846..7c9e115a0 100644
--- a/internal/host/movespolicy.go
+++ b/internal/host/movespolicy.go
@@ -69,9 +69,10 @@ import (
// and held-release, restored restore and install, each under the claim
// at claimed, for a dirty rollback, with the holder at the source's
// pre-move stamp S (op.journal.source_stamp); probing grants the held
-// config render before the probe (§7.7) and spawn, under the claim at
-// probing. reverting is keyed by the claim's rev_phase: probing grants
-// the held config render and spawn, with op.phase at probing (J1);
+// config render and the release materialization before every probe
+// (§7.7) and spawn, under the claim at probing. reverting is keyed by
+// the claim's rev_phase: probing grants the same three, with op.phase at
+// probing (J1);
// - the identity fits the action, a spawn carrying the launch identity
// (Plan 3 H4): the config and the release it launches with;
// - the held config and release and the launch name the inputs the
@@ -80,8 +81,9 @@ import (
// they are recorded the render is granted on the rows above: configured
// is the render the record takes them from, and a clean return renders
// before its probe with no configured. A dirty return's probe, and every
-// spawn, need them recorded. A revert records none: its render and
-// launch are bound by shape only.
+// spawn, need them recorded. A revert records none: its render, its
+// release and its launch are bound by shape only (a revert inputs field
+// is carried to Tasks 6 and 9).
//
// The target's cleaning (ReplaceTimers(host, nil) at cleaning) and the
// source's revert close are not rows here: their callers use plain registry
@@ -327,13 +329,13 @@ var restoringRights = map[string]recoveryRow{
provision.RbSourceLocal: {claimPhase: hostclaim.PhaseClaimed, install: true, actions: map[string]bool{movesActionHeldTimers: true}},
provision.RbSourceConfigured: {claimPhase: hostclaim.PhaseClaimed, install: true, actions: map[string]bool{movesActionHeldConfig: true, movesActionHeldRelease: true}},
provision.RbSourceRestored: {claimPhase: hostclaim.PhaseClaimed, install: true, actions: map[string]bool{movesActionRestore: true, movesActionInstall: true}},
- provision.RbSourceProbing: {claimPhase: hostclaim.PhaseProbing, actions: map[string]bool{movesActionHeldConfig: true, movesActionSpawn: true}},
+ provision.RbSourceProbing: {claimPhase: hostclaim.PhaseProbing, actions: map[string]bool{movesActionHeldConfig: true, movesActionHeldRelease: true, movesActionSpawn: true}},
}
// revertingRights is the source's table during a revert, keyed by the
// claim's rev_phase (§7.4), which op.phase mirrors from probing on (J1).
var revertingRights = map[string]recoveryRow{
- hostclaim.PhaseProbing: {claimPhase: hostclaim.PhaseProbing, actions: map[string]bool{movesActionHeldConfig: true, movesActionSpawn: true}},
+ hostclaim.PhaseProbing: {claimPhase: hostclaim.PhaseProbing, actions: map[string]bool{movesActionHeldConfig: true, movesActionHeldRelease: true, movesActionSpawn: true}},
}
// movesRecoveryPolicy is MovesClaimPolicy's reverting and restoring rows:
@@ -406,8 +408,8 @@ func movesRecoveryPolicy(ctx context.Context, read MovesRecordReader, c *hostcla
return refuse("the identity does not fit the action", nil)
}
if rb == nil {
- // A revert records no recovery inputs: its render and its launch
- // are bound by shape, never by Move.Inputs.
+ // A revert records no recovery inputs: its render, its release and
+ // its launch are bound by shape, never by Move.Inputs.
return nil
}
switch s.Action {
diff --git a/internal/host/movespolicy_test.go b/internal/host/movespolicy_test.go
index a1fe4a571..490e05cc0 100644
--- a/internal/host/movespolicy_test.go
+++ b/internal/host/movespolicy_test.go
@@ -809,14 +809,15 @@ func rpRefused(t *testing.T, err error, reason string) {
// phase of the rollback, under the claim phase that phase runs under. The
// dirty install's held writes are granted at their phases (timers at local,
// config and release at configured, restore and install at restored), and
-// the held config render and the spawn at probing; nothing else. A record
+// the held config render, the release materialization and the spawn at
+// probing (§7.7); nothing else. A record
// phase under the wrong claim phase is refused.
func TestMovesClaimPolicyRestoringRows(t *testing.T) {
granted := map[string]map[string]bool{
provision.RbSourceLocal: {"held-timers": true},
provision.RbSourceConfigured: {"held-config": true, "held-release": true},
provision.RbSourceRestored: {"restore": true, "install": true},
- provision.RbSourceProbing: {"held-config": true, "spawn": true},
+ provision.RbSourceProbing: {"held-config": true, "held-release": true, "spawn": true},
}
grants := 0
for _, phase := range rpSourcePhases {
@@ -843,8 +844,8 @@ func TestMovesClaimPolicyRestoringRows(t *testing.T) {
t.Errorf("%s: the rows are keyed by the record: %d reads %v", phase, reader.reads, reader.args)
}
}
- if grants != 7 {
- t.Fatalf("%d cells granted, want 7", grants)
+ if grants != 8 {
+ t.Fatalf("%d cells granted, want 8", grants)
}
t.Run("the wrong claim phase", func(t *testing.T) {
for _, tc := range []struct{ record, claim, action string }{
@@ -862,8 +863,8 @@ func TestMovesClaimPolicyRestoringRows(t *testing.T) {
}
// reverting, the source's pre-flip recovery (J12): the rows are keyed by the
-// claim's rev_phase. Only probing grants: the held config render before each
-// probe and the spawn. The same cells through hostbox.Policy.Check with the
+// claim's rev_phase. Only probing grants: the held config render and the
+// release materialization before each probe (§7.7), and the spawn. The same cells through hostbox.Policy.Check with the
// revert's hold, the move id.
func TestMovesClaimPolicyRevertingRows(t *testing.T) {
reader := &mpReader{rec: rpRevertingRecord()}
@@ -875,7 +876,7 @@ func TestMovesClaimPolicyRevertingRows(t *testing.T) {
c := rpRevertingClaim(rev)
s := rpSelection(c, action)
err := moves(t.Context(), c, s, rpIdentity(action))
- want := rev == hostclaim.PhaseProbing && (action == "held-config" || action == "spawn")
+ want := rev == hostclaim.PhaseProbing && (action == "held-config" || action == "held-release" || action == "spawn")
if (err == nil) != want {
t.Errorf("%s/%s: err %v, want granted %v", rev, action, err, want)
continue
@@ -891,8 +892,8 @@ func TestMovesClaimPolicyRevertingRows(t *testing.T) {
}
}
}
- if grants != 2 {
- t.Fatalf("%d cells granted, want 2", grants)
+ if grants != 3 {
+ t.Fatalf("%d cells granted, want 3", grants)
}
t.Run("rev_phase and op.phase disagree", func(t *testing.T) {
c := rpRevertingClaim(hostclaim.PhaseProbing)
@@ -1178,7 +1179,12 @@ func TestMovesClaimPolicyBindsRecoveryInputs(t *testing.T) {
rpRefused(t, moves(t.Context(), c, rpSelection(c, "spawn"), id), "the identity does not fit the action")
}
rpRefused(t, moves(t.Context(), c, rpSelection(c, "held-config"), rpLaunch(recovered)), "the identity does not fit the action")
- rpRefused(t, moves(t.Context(), c, rpSelection(c, "held-release"), hostwrite.Identity{Release: "v1.3.0"}), "the phase does not permit the action")
+ for _, in := range []*provision.MoveInputs{recovered, pinned} {
+ if err := moves(t.Context(), c, rpSelection(c, "held-release"), hostwrite.Identity{Release: in.Release}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ rpRefused(t, moves(t.Context(), c, rpSelection(c, "held-release"), rpLaunch(recovered)), "the identity does not fit the action")
})
t.Run("a landing: the same difference is refused", func(t *testing.T) {
moves, _ := mpPolicy()
@@ -1232,3 +1238,91 @@ func TestMovesClaimPolicyRestoringThroughPolicyCheck(t *testing.T) {
t.Fatalf("held-timers at configured: %v", err)
}
}
+
+// The release is materialized before every recovery probe (§7.7; Task 3b
+// fix round 2), through hostbox.Policy.Check with each recovery's own hold:
+// held-release at probing is granted to the executing source, bound to the
+// recorded recovery inputs on a return and by shape on a revert, and
+// refused at another phase, to another box, and for a release that is not
+// the recorded one.
+func TestMovesClaimPolicyRecoveryReleaseAtProbing(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)
+ }
+ recovered, pinned := rpRecovery(), mpPins()
+ t.Run("restoring", func(t *testing.T) {
+ c := rpRestoringClaim(hostclaim.PhaseProbing)
+ s := rpSelection(c, "held-release")
+ rec := rpRestoringRecord(provision.RbSourceProbing)
+ if err := through(rpRB, rec, c, s, hostwrite.Identity{Release: recovered.Release}); err != nil {
+ t.Fatalf("the executor at probing: %v", err)
+ }
+ for _, v := range []string{pinned.Release, "v1.3.1", ""} {
+ if err := through(rpRB, rec, c, s, hostwrite.Identity{Release: v}); err == nil {
+ t.Fatalf("release %q granted against the recorded %q", v, recovered.Release)
+ }
+ }
+ rpRefused(t, through(rpRB, rec, c, s, hostwrite.Identity{Release: pinned.Release}), "the release is not the recovery's")
+ // The wrong phase: the record past probing, or the claim behind it.
+ for _, tc := range []struct{ record, claim string }{
+ {provision.RbSourceProbed, hostclaim.PhaseProbed},
+ {provision.RbSourceProbing, hostclaim.PhaseClaimed},
+ {provision.RbSourceRestored, hostclaim.PhaseClaimed},
+ } {
+ c := rpRestoringClaim(tc.claim)
+ if err := through(rpRB, rpRestoringRecord(tc.record), c, rpSelection(c, "held-release"), hostwrite.Identity{Release: recovered.Release}); err == nil {
+ t.Fatalf("record %s, claim %s: granted", tc.record, tc.claim)
+ }
+ }
+ // The wrong box: the target asking, or a third box executing.
+ t2 := rpSelection(c, "held-release")
+ t2.Box, t2.Region = mpTarget, "uk"
+ if err := through(rpRB, rec, c, t2, hostwrite.Identity{Release: recovered.Release}); err == nil {
+ t.Fatal("the target granted")
+ }
+ c3 := rpRestoringClaim(hostclaim.PhaseProbing)
+ c3.Op.Exec = "de-box"
+ s3 := rpSelection(c3, "held-release")
+ s3.Box = "de-box"
+ if err := through(rpRB, rec, c3, s3, hostwrite.Identity{Release: recovered.Release}); err == nil {
+ t.Fatal("a third box granted")
+ }
+ // A clean return materializes before its inputs are recorded; a dirty
+ // one needs them.
+ clean := rpRestoringRecord(provision.RbSourceProbing)
+ clean.Move.Rollback.Dirty, clean.Move.Rollback.RecoveryInputs = false, nil
+ if err := through(rpRB, clean, c, s, hostwrite.Identity{Release: "v9"}); err != nil {
+ t.Fatalf("a clean return before the record: %v", err)
+ }
+ dirty := rpRestoringRecord(provision.RbSourceProbing)
+ dirty.Move.Rollback.RecoveryInputs = nil
+ rpRefused(t, through(rpRB, dirty, c, s, hostwrite.Identity{Release: recovered.Release}), "no recorded recovery inputs")
+ })
+ t.Run("reverting", func(t *testing.T) {
+ c := rpRevertingClaim(hostclaim.PhaseProbing)
+ s := rpSelection(c, "held-release")
+ rec := rpRevertingRecord()
+ for _, v := range []string{recovered.Release, pinned.Release} {
+ if err := through(mpOp, rec, c, s, hostwrite.Identity{Release: v}); err != nil {
+ t.Fatalf("the executor at probing, release %s: %v", v, err)
+ }
+ }
+ rpRefused(t, through(mpOp, rec, c, s, hostwrite.Identity{Release: recovered.Release, Config: "x"}), "the identity does not fit the action")
+ for _, rev := range []string{hostclaim.RevPhaseEvidence, hostclaim.RevPhaseVerify, hostclaim.PhaseProbed, hostclaim.PhaseOpened} {
+ c := rpRevertingClaim(rev)
+ if err := through(mpOp, rec, c, rpSelection(c, "held-release"), hostwrite.Identity{Release: recovered.Release}); err == nil {
+ t.Fatalf("rev_phase %s: granted", rev)
+ }
+ }
+ t2 := rpSelection(c, "held-release")
+ t2.Box, t2.Region = mpTarget, "uk"
+ if err := through(mpOp, rec, c, t2, hostwrite.Identity{Release: recovered.Release}); err == nil {
+ t.Fatal("the target granted")
+ }
+ // A revert's row is held by the move id, never a rollback's.
+ if err := through(rpRB, rec, c, s, hostwrite.Identity{Release: recovered.Release}); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("a rollback hold on a revert: %v", err)
+ }
+ })
+}