carlos / platform Public

provision: MoveRecordRecoveryInputs, a recovery probe's inputs recorded before it renders (Plan 4 Task 5)

A rollback's recovery probe records the bundle, overlay and release it is
about to render, on this rollback while its source is at probing, and
nothing else (spec 7.7, J2-R2-5; Task 3b concern 3). Never compared with
move.inputs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8e9dcb31ca57682d523eed1e26b782b5cf0e3595 parent b9bca4a
2 files changed, +97 −0
  • internal/provision/move_test.go +66 −0
  • internal/provision/provision.go +31 −0
diff --git a/internal/provision/move_test.go b/internal/provision/move_test.go
index ab5636b57..08bec8262 100644
--- a/internal/provision/move_test.go
+++ b/internal/provision/move_test.go
@@ -1496,3 +1496,69 @@ func TestRollbackIDShape(t *testing.T) {
}
}
}
+
+// A recovery probe records the inputs it renders before it renders them
+// (Plan 4 Task 5; §7.7, J2-R2-5; Task 3b concern 3): MoveRecordRecoveryInputs
+// writes Rollback.RecoveryInputs on this rollback while its source is at
+// probing, and nothing else. It replaces what configured recorded (a bundle
+// published since is the probe's to render, never compared with
+// move.inputs), and is refused at any other phase, on another rollback, on
+// a stuck record and without a release.
+func TestRecoveryInputsRecordedAtProbing(t *testing.T) {
+ g := cutTestGuard()
+ now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ at := func(phase string, dirty bool) *Record {
+ rec := rollingBackRecord(t, g, now)
+ rec.Move.Rollback.TargetPhase, rec.Move.Rollback.SourcePhase, rec.Move.Rollback.Dirty = RbTargetReturned, phase, dirty
+ rec.Move.Inputs = &MoveInputs{BundleETags: map[string]string{"default": "e1"}, Release: "v7"}
+ return rec
+ }
+ first := MoveInputs{BundleETags: map[string]string{"default": "e2"}, OverlayETag: "o2", Release: "v8"}
+ later := MoveInputs{BundleETags: map[string]string{"default": "e3"}, Release: "v8"}
+ for _, dirty := range []bool{false, true} {
+ rec := at(RbSourceProbing, dirty)
+ if dirty {
+ rec.Move.Rollback.RecoveryInputs = &MoveInputs{BundleETags: map[string]string{"default": "e2"}, Release: "v8"}
+ }
+ if err := MoveRecordRecoveryInputs(g, rbTestID, first)(rec); err != nil {
+ t.Fatalf("dirty %v: %v", dirty, err)
+ }
+ rb := rec.Move.Rollback
+ if rb.RecoveryInputs == nil || rb.RecoveryInputs.BundleETags["default"] != "e2" || rb.RecoveryInputs.OverlayETag != "o2" || rb.RecoveryInputs.Release != "v8" {
+ t.Fatalf("dirty %v: recorded %+v", dirty, rb.RecoveryInputs)
+ }
+ if rec.Move.Inputs.BundleETags["default"] != "e1" || rec.Move.Inputs.Release != "v7" || rb.SourcePhase != RbSourceProbing || rec.Move.State != MoveRollingBack {
+ t.Fatalf("dirty %v: the record changed beyond the inputs: %+v %+v", dirty, rec.Move, rb)
+ }
+ // The caller's map is copied, not aliased.
+ first.BundleETags["default"] = "mutated"
+ if rb.RecoveryInputs.BundleETags["default"] != "e2" {
+ t.Fatalf("dirty %v: the record aliases the caller's map", dirty)
+ }
+ first.BundleETags["default"] = "e2"
+ // A restarted probe records what it renders now.
+ if err := MoveRecordRecoveryInputs(g, rbTestID, later)(rec); err != nil || rb.RecoveryInputs.BundleETags["default"] != "e3" {
+ t.Fatalf("dirty %v: a later probe: %v %+v", dirty, err, rb.RecoveryInputs)
+ }
+ }
+ for _, phase := range []string{RbSourceWaiting, RbSourceClaimed, RbSourceRestoring, RbSourceVerified, RbSourceConfigured, RbSourceRestored, RbSourceProbed, RbSourceRecovered, RbSourceOpened} {
+ rec := at(phase, phase == RbSourceConfigured || phase == RbSourceRestored)
+ if err := MoveRecordRecoveryInputs(g, rbTestID, first)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Rollback.RecoveryInputs != nil {
+ t.Fatalf("at %s: %v %+v", phase, err, rec.Move.Rollback.RecoveryInputs)
+ }
+ }
+ rec := at(RbSourceProbing, false)
+ if err := MoveRecordRecoveryInputs(g, "rb_bbbbbbbbbbbbbbbbbbbbbbbbbb", first)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Rollback.RecoveryInputs != nil {
+ t.Fatalf("another rollback: %v", err)
+ }
+ if err := MoveRecordRecoveryInputs(g, rbTestID, MoveInputs{BundleETags: map[string]string{"default": "e2"}})(rec); err == nil || rec.Move.Rollback.RecoveryInputs != nil {
+ t.Fatalf("no release: %v", err)
+ }
+ if err := MoveRecordRecoveryInputs(MoveGuard{ID: g.ID, Incarnation: strings.Repeat("cd", 32)}, rbTestID, first)(rec); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("another guard: %v", err)
+ }
+ rec.Move.State = MoveStuck
+ if err := MoveRecordRecoveryInputs(g, rbTestID, first)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.Rollback.RecoveryInputs != nil {
+ t.Fatalf("stuck: %v", err)
+ }
+}
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 6a3c3f657..a173e2586 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -2016,6 +2016,37 @@ func MoveRollbackSourcePhase(g MoveGuard, from, to string, stamp func(*MoveRollb
}
}
+// MoveRecordRecoveryInputs records the inputs a rollback's recovery probe
+// is about to render (§7.7, J2-R2-5; Plan 4 Task 5, Task 3b concern 3): the
+// probe renders the current bundle, overlay and release and records them
+// first, so the held render and the launch it then writes are bound to what
+// the record says (MovesClaimPolicy). It is legal only on this rollback
+// (rbID) while its source is at probing, and writes Rollback.RecoveryInputs
+// alone: a dirty return's configured recorded its own, and a restarted
+// probe's are the ones it renders now; neither is ever compared with
+// move.inputs. The release must be named. in is copied.
+func MoveRecordRecoveryInputs(g MoveGuard, rbID string, in MoveInputs) func(*Record) error {
+ return func(rec *Record) error {
+ if in.Release == "" {
+ return fmt.Errorf("provision: recovery inputs name the release")
+ }
+ rb, err := rollingBack(g, rec)
+ if err != nil {
+ return err
+ }
+ if rb.ID != rbID || rb.SourcePhase != RbSourceProbing {
+ return fmt.Errorf("%w: recovery inputs are recorded by rollback %s at probing (the record is %s at %s)", ErrMoveConflict, rbID, rb.ID, rb.SourcePhase)
+ }
+ cp := in
+ cp.BundleETags = make(map[string]string, len(in.BundleETags))
+ for k, v := range in.BundleETags {
+ cp.BundleETags[k] = v
+ }
+ rb.RecoveryInputs = &cp
+ return nil
+ }
+}
+
// MoveMarkStuck stops a recovery for an operator (§7.8 stuck, §7.4
// reverting): rolling_back or reverting to stuck, with a recorded reason
// in Move.StuckReason, the one field it writes besides the state and