carlos / platform Public

provision: MoveRecordRevertInputs, a revert's probe inputs recorded before it renders (Plan 4 Task 6)

A revert has no Rollback journal, so the inputs its probe renders are the
move's own RecoveryInputs, recorded on a reverting move that has not
reopened and never compared with move.inputs (J18).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev c74408612d20da9082572cd64ad47cd2fe8d74bf parent 30972b9
2 files changed, +93 −0
  • internal/provision/move_test.go +59 −0
  • internal/provision/provision.go +34 −0
diff --git a/internal/provision/move_test.go b/internal/provision/move_test.go
index 08bec8262..53038d40a 100644
--- a/internal/provision/move_test.go
+++ b/internal/provision/move_test.go
@@ -1562,3 +1562,62 @@ func TestRecoveryInputsRecordedAtProbing(t *testing.T) {
t.Fatalf("stuck: %v", err)
}
}
+
+// A revert's probe records the inputs it renders before it renders them
+// (Plan 4 Task 6; J18, §7.7): MoveRecordRevertInputs writes the move's own
+// RecoveryInputs on a reverting record that has not reopened, and nothing
+// else. A later probe replaces them; they are never compared with
+// move.inputs. It is refused on any other state, after the reopen, without a
+// release and under another guard.
+func TestRevertInputsRecordedBeforeTheRender(t *testing.T) {
+ g := cutTestGuard()
+ now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ reverting := func() *Record {
+ return &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveReverting, CutPhase: CutStopping,
+ Reason: RevertStopEvidenceBudget, Inputs: &MoveInputs{BundleETags: map[string]string{"default": "e1"}, Release: "v7"}}}
+ }
+ first := MoveInputs{BundleETags: map[string]string{"default": "e2"}, OverlayETag: "o2", Release: "v8"}
+ rec := reverting()
+ if err := MoveRecordRevertInputs(g, first)(rec); err != nil {
+ t.Fatal(err)
+ }
+ m := rec.Move
+ if m.RecoveryInputs == nil || m.RecoveryInputs.BundleETags["default"] != "e2" || m.RecoveryInputs.OverlayETag != "o2" || m.RecoveryInputs.Release != "v8" {
+ t.Fatalf("recorded %+v", m.RecoveryInputs)
+ }
+ if m.Inputs.BundleETags["default"] != "e1" || m.Inputs.Release != "v7" || m.State != MoveReverting || m.Reason != RevertStopEvidenceBudget || m.CutPhase != CutStopping {
+ t.Fatalf("the record changed beyond the inputs: %+v", m)
+ }
+ first.BundleETags["default"] = "mutated"
+ if m.RecoveryInputs.BundleETags["default"] != "e2" {
+ t.Fatal("the record aliases the caller's map")
+ }
+ if err := MoveRecordRevertInputs(g, MoveInputs{BundleETags: map[string]string{"default": "e3"}, Release: "v8"})(rec); err != nil || m.RecoveryInputs.BundleETags["default"] != "e3" {
+ t.Fatalf("a later probe: %v %+v", err, m.RecoveryInputs)
+ }
+ for _, state := range []string{MoveCutting, MoveLanding, MoveRollingBack, MoveStuck, MoveCommitted, MoveAborting} {
+ rec := reverting()
+ rec.Move.State = state
+ if err := MoveRecordRevertInputs(g, first)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.RecoveryInputs != nil {
+ t.Fatalf("from %s: %v", state, err)
+ }
+ }
+ rec = reverting()
+ rec.Move.ReopenedAt = &now
+ if err := MoveRecordRevertInputs(g, first)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.RecoveryInputs != nil {
+ t.Fatalf("after the reopen: %v", err)
+ }
+ rec = reverting()
+ if err := MoveRecordRevertInputs(g, MoveInputs{BundleETags: map[string]string{"default": "e2"}})(rec); err == nil || rec.Move.RecoveryInputs != nil {
+ t.Fatalf("no release: %v", err)
+ }
+ if err := MoveRecordRevertInputs(MoveGuard{ID: g.ID, Incarnation: strings.Repeat("cd", 32)}, first)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.RecoveryInputs != nil {
+ t.Fatalf("another guard: %v", err)
+ }
+ if raw, _ := json.Marshal(MoveState{}); strings.Contains(string(raw), "recovery_inputs") {
+ t.Fatalf("recovery_inputs is not omitempty: %s", raw)
+ }
+ if raw, _ := json.Marshal(MoveState{RecoveryInputs: &MoveInputs{Release: "v8"}}); !strings.Contains(string(raw), `"recovery_inputs":{`) {
+ t.Fatalf("recovery_inputs missing: %s", raw)
+ }
+}
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index a173e2586..ae41b2a1c 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -972,6 +972,11 @@ type MoveState struct {
// (rev_phase probed, ruling J26): a revert has no Rollback journal, and
// MoveRevertClose counts the offline time from CutAt to this stamp.
ReopenedAt *time.Time `json:"reopened_at,omitempty"`
+ // RecoveryInputs are the inputs a revert's probe rendered (Plan 4 Task
+ // 6; §7.7, J18), recorded before the render by MoveRecordRevertInputs;
+ // never compared with Inputs. A rollback keeps its own in
+ // Rollback.RecoveryInputs.
+ RecoveryInputs *MoveInputs `json:"recovery_inputs,omitempty"`
Rollback *MoveRollback `json:"rollback,omitempty"`
Reason string `json:"reason,omitempty"`
@@ -2266,6 +2271,35 @@ func MoveRevertReopened(g MoveGuard, now time.Time) func(*Record) error {
}
}
+// MoveRecordRevertInputs records the inputs a revert's probe is about to
+// render (Plan 4 Task 6; §7.7, J18): the current bundle, overlay and release,
+// written before the render so the held render and the launch are bound to
+// what the record says. It is legal on a reverting move that has not
+// reopened (a revert has no Rollback journal, so the inputs are the move's
+// own RecoveryInputs), and writes nothing else; a later probe's replace an
+// earlier one's. They are never compared with Inputs. The release must be
+// named. in is copied.
+func MoveRecordRevertInputs(g MoveGuard, in MoveInputs) func(*Record) error {
+ return func(rec *Record) error {
+ if in.Release == "" {
+ return fmt.Errorf("provision: recovery inputs name the release")
+ }
+ if err := g.check(rec); err != nil {
+ return err
+ }
+ if rec.Move.State != MoveReverting || rec.Move.ReopenedAt != nil {
+ return fmt.Errorf("%w: a revert's recovery inputs are recorded before it reopens", ErrMoveConflict)
+ }
+ cp := in
+ cp.BundleETags = make(map[string]string, len(in.BundleETags))
+ for k, v := range in.BundleETags {
+ cp.BundleETags[k] = v
+ }
+ rec.Move.RecoveryInputs = &cp
+ return nil
+ }
+}
+
// MoveRevertClose is a revert's close (§7.4 reverting, index D11): the
// caller has seen the claim's rev_phase at opened; the record needs its
// ReopenedAt. Move is cleared and LastMove says rolled_back, the offline