carlos / platform Public

provision, host: MoveAdmit records the admitted probe attempts (Plan 3 Task 6, H10)

MoveAdmit takes the target report's AttemptsAdmitted, the count its T11
pricing admitted, and records it as CutAttempts (additive, omitempty); a
negative count is refused. RollbackProbeUnpriced is the landing's
recorded refusal when the count is absent (MV-N187, pending), accepted
by MoveMarkRollback and MoveRollBack.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 131f1d8ee5488418dda1b25948dc4772b1e9d4bc parent ba7aa3f
7 files changed, +64 −18
  • approved_moves_copy_test.go +1 −1
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +1 −0
  • internal/host/movescut.go +1 −1
  • internal/host/movescut_test.go +3 −3
  • internal/provision/move_probeattempts_test.go +30 −0
  • internal/provision/move_test.go +5 −5
  • internal/provision/provision.go +23 −8
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 89f05f20f..f4591b472 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -70,7 +70,7 @@ var movesCopyIDs = []string{
"MV-N163", "MV-N164", "MV-N165", "MV-N166", "MV-N167", "MV-N168",
"MV-N169", "MV-N170", "MV-N171", "MV-N172", "MV-N173", "MV-N174",
"MV-N175", "MV-N176", "MV-N177", "MV-N178", "MV-N179", "MV-N180", "MV-N181",
- "MV-N182", "MV-N183", "MV-N184", "MV-N185", "MV-N186",
+ "MV-N182", "MV-N183", "MV-N184", "MV-N185", "MV-N186", "MV-N187",
"MV-N300", "MV-N301", "MV-N302", "MV-N303", "MV-N304", "MV-N305", "MV-N306",
"MV-N307", "MV-N308", "MV-N309", "MV-N310", "MV-N311", "MV-N312",
}
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
index b56d80ec7..64d2daf10 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -240,6 +240,7 @@ Columns: id, file, text, status.
| MV-N184 | internal/moves/startcheck.go | this box holds another generation of the instance | approved |
| MV-N185 | internal/moves/startcheck.go | the instance's files belong to another generation | approved |
| MV-N186 | internal/moves/startcheck.go | the instance's database belongs to another generation | approved |
+| MV-N187 | internal/provision/provision.go | the cut recorded no probe attempts for this landing | pending |
## Plan 4
diff --git a/internal/host/movescut.go b/internal/host/movescut.go
index fbf4d0cb8..8c10e18b1 100644
--- a/internal/host/movescut.go
+++ b/internal/host/movescut.go
@@ -239,7 +239,7 @@ func (c *cutRun) requested(ctx context.Context) (bool, error) {
}
now := cfg.Now()
budget := target.CutBudgetS
- return c.cutTransition(ctx, "the admission", provision.MoveAdmit(c.g, now, budget, moves.CutDeadline(now, float64(budget))))
+ return c.cutTransition(ctx, "the admission", provision.MoveAdmit(c.g, now, budget, moves.CutDeadline(now, float64(budget)), target.AttemptsAdmitted))
}
// admitted (§7.4): the pinned inputs still match the current bundle and
diff --git a/internal/host/movescut_test.go b/internal/host/movescut_test.go
index 38617cb8d..a94566d51 100644
--- a/internal/host/movescut_test.go
+++ b/internal/host/movescut_test.go
@@ -331,7 +331,7 @@ func TestCutGoesBackToReadyWhenInputsMove(t *testing.T) {
h2.ready(t)
h2.cut(t)
now := h2.clock.Now()
- if _, err := provision.SetRecordMove(ctx, h2.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120))); err != nil {
+ if _, err := provision.SetRecordMove(ctx, h2.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120), 3)); err != nil {
t.Fatal(err)
}
h2.bundle["default"] = "e9"
@@ -423,7 +423,7 @@ func TestCutRefusesAScopedHolder(t *testing.T) {
h2.ready(t)
h2.cut(t)
now := h2.clock.Now()
- if _, err := provision.SetRecordMove(ctx, h2.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120))); err != nil {
+ if _, err := provision.SetRecordMove(ctx, h2.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120), 3)); err != nil {
t.Fatal(err)
}
h2.pass(t)
@@ -985,7 +985,7 @@ func TestCutDeadlinePassedReverts(t *testing.T) {
h.ready(t)
h.cut(t)
now := h.clock.Now()
- if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120))); err != nil {
+ if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120), 3)); err != nil {
t.Fatal(err)
}
h.clock.Advance(2 * time.Minute)
diff --git a/internal/provision/move_probeattempts_test.go b/internal/provision/move_probeattempts_test.go
new file mode 100644
index 000000000..ddc49a441
--- /dev/null
+++ b/internal/provision/move_probeattempts_test.go
@@ -0,0 +1,30 @@
+package provision
+
+import (
+ "errors"
+ "testing"
+ "time"
+)
+
+// H10 (moves Plan 3 Task 6): the admission records the probe attempts its
+// T11 pricing admitted, and an absent count is the landing's recorded
+// refusal of the probe.
+func TestMoveAdmitRecordsTheProbeAttempts(t *testing.T) {
+ g := cutTestGuard()
+ now := time.Date(2026, 10, 9, 9, 0, 0, 0, time.UTC)
+ cutting := func() *Record {
+ return &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveCutting, CutPhase: CutRequested, Inputs: &MoveInputs{Release: "v1"}}}
+ }
+ rec := cutting()
+ if err := MoveAdmit(g, now, 120, now.Add(time.Minute), 2)(rec); err != nil || rec.Move.CutAttempts != 2 {
+ t.Fatalf("admit: %v %+v", err, rec.Move)
+ }
+ rec = cutting()
+ if err := MoveAdmit(g, now, 120, now.Add(time.Minute), -1)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.CutPhase != CutRequested || rec.Move.CutAttempts != 0 {
+ t.Fatalf("a negative count: %v %+v", err, rec.Move)
+ }
+ landing := &Record{Region: "uk", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveLanding, LandingPhase: LandProbing}}
+ if err := MoveMarkRollback(g, RollbackProbeUnpriced)(landing); err != nil || landing.Move.Reason != RollbackProbeUnpriced {
+ t.Fatalf("the unpriced probe's mark: %v %+v", err, landing.Move)
+ }
+}
diff --git a/internal/provision/move_test.go b/internal/provision/move_test.go
index 4a692bd50..46c8d3714 100644
--- a/internal/provision/move_test.go
+++ b/internal/provision/move_test.go
@@ -275,7 +275,7 @@ func TestCutPhasesAdvanceInOrderUnderTheGuard(t *testing.T) {
t.Fatalf("auto cut: %v %+v", err, rec.Move)
}
deadline := now.Add(115 * time.Second)
- if err := MoveAdmit(g, now, 120, deadline)(rec); err != nil || rec.Move.CutPhase != CutAdmitted || !rec.Move.CutDeadline.Equal(deadline) || !rec.Move.CutAt.Equal(now) || rec.Move.CutBudgetS != 120 || rec.Move.Inputs.Release != "v1" {
+ if err := MoveAdmit(g, now, 120, deadline, 3)(rec); err != nil || rec.Move.CutPhase != CutAdmitted || !rec.Move.CutDeadline.Equal(deadline) || !rec.Move.CutAt.Equal(now) || rec.Move.CutBudgetS != 120 || rec.Move.Inputs.Release != "v1" {
t.Fatalf("admit: %v %+v", err, rec.Move)
}
// Wrong incarnation: refused.
@@ -487,12 +487,12 @@ func TestInputsLoopPinDriftBackRepinCutAdmit(t *testing.T) {
t.Fatalf("repin in cutting: %v", err)
}
// Admit pins nothing: it stamps the clock fields and keeps the pin.
- if err := MoveAdmit(g, now, 120, now.Add(time.Minute))(rec); err != nil || rec.Move.CutPhase != CutAdmitted || rec.Move.Inputs.Release != "v2" {
+ if err := MoveAdmit(g, now, 120, now.Add(time.Minute), 3)(rec); err != nil || rec.Move.CutPhase != CutAdmitted || rec.Move.Inputs.Release != "v2" {
t.Fatalf("admit: %v %+v", err, rec.Move)
}
// Admit on a cut with no pin is refused and writes nothing.
bare := &Record{Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveCutting, CutPhase: CutRequested}}
- if err := MoveAdmit(g, now, 120, now.Add(time.Minute))(bare); !errors.Is(err, ErrMoveConflict) || bare.Move.CutPhase != CutRequested || bare.Move.CutAt != nil || bare.Move.CutBudgetS != 0 {
+ if err := MoveAdmit(g, now, 120, now.Add(time.Minute), 3)(bare); !errors.Is(err, ErrMoveConflict) || bare.Move.CutPhase != CutRequested || bare.Move.CutAt != nil || bare.Move.CutBudgetS != 0 {
t.Fatalf("admit with no pin: %v %+v", err, bare.Move)
}
}
@@ -539,7 +539,7 @@ func TestFlipIsOneRecordWrite(t *testing.T) {
MoveStartTransition(MoveStart{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", Cut: "auto", By: "ops@example.com", Now: now}),
MoveGuardedPromoteReady(g, now, MoveInputs{Release: "v1"}),
MoveGuardedCut(g),
- MoveAdmit(g, now, 120, now.Add(115*time.Second)),
+ MoveAdmit(g, now, 120, now.Add(115*time.Second), 3),
MoveCutPhase(g, CutAdmitted, CutStopping, nil),
MoveCutPhase(g, CutStopping, CutStopped, nil),
MoveCutPhase(g, CutStopped, CutStaged, nil),
@@ -579,7 +579,7 @@ func TestBackToReadyRecordsARefusal(t *testing.T) {
}
rec.Move.Inputs = &MoveInputs{Release: "v1"}
rec.Move.State, rec.Move.CutPhase = MoveCutting, CutRequested
- if err := MoveAdmit(g, now, 120, now)(rec); err != nil || rec.Move.Reason != "" {
+ if err := MoveAdmit(g, now, 120, now, 3)(rec); err != nil || rec.Move.Reason != "" {
t.Fatalf("the admission did not clear the reason: %v %+v", err, rec.Move)
}
}
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index cda7ff114..57ca898d1 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -957,9 +957,13 @@ type MoveState struct {
// admission (§7.3 T3, §7.4 admitted, INV-6).
Inputs *MoveInputs `json:"inputs,omitempty"`
- CutPhase string `json:"cut_phase,omitempty"`
- CutAt *time.Time `json:"cut_at,omitempty"`
- CutBudgetS int `json:"cut_budget_s,omitempty"`
+ CutPhase string `json:"cut_phase,omitempty"`
+ CutAt *time.Time `json:"cut_at,omitempty"`
+ CutBudgetS int `json:"cut_budget_s,omitempty"`
+ // CutAttempts is the number of probe attempts the admission's T11
+ // pricing admitted (the target report's AttemptsAdmitted, H10). The
+ // landing's probe reads it; absent refuses the probe.
+ CutAttempts int `json:"cut_attempts,omitempty"`
CutDeadline *time.Time `json:"cut_deadline,omitempty"`
Source *MoveSource `json:"source,omitempty"`
@@ -1417,12 +1421,12 @@ func MoveCutPhase(g MoveGuard, from, to string, stamp func(*MoveState)) func(*Re
}
}
-// MoveAdmit is §7.4 requested to admitted: the budget and the absolute
-// deadline, in one write. It pins nothing: the inputs were pinned at
+// MoveAdmit is §7.4 requested to admitted: the budget, the absolute
+// deadline and the probe attempts T11 admitted (H10), in one write. It pins nothing: the inputs were pinned at
// readiness (MoveGuardedPromoteReady, or MoveGuardedRepin after a back to
// ready), and the source compares the current inputs with that pin before
// this CAS. A cut with no pin is refused.
-func MoveAdmit(g MoveGuard, now time.Time, budgetS int, deadline time.Time) func(*Record) error {
+func MoveAdmit(g MoveGuard, now time.Time, budgetS int, deadline time.Time, attempts int) func(*Record) error {
return func(rec *Record) error {
if err := g.check(rec); err != nil {
return err
@@ -1430,9 +1434,12 @@ func MoveAdmit(g MoveGuard, now time.Time, budgetS int, deadline time.Time) func
if rec.Move.Inputs == nil {
return fmt.Errorf("%w: the move has no pinned inputs to admit against", ErrMoveConflict)
}
+ if attempts < 0 {
+ return fmt.Errorf("%w: a negative count of probe attempts", ErrMoveConflict)
+ }
return MoveCutPhase(g, CutRequested, CutAdmitted, func(m *MoveState) {
t, d := now.UTC(), deadline.UTC()
- m.CutAt, m.CutBudgetS, m.CutDeadline = &t, budgetS, &d
+ m.CutAt, m.CutBudgetS, m.CutDeadline, m.CutAttempts = &t, budgetS, &d, attempts
// A refusal an earlier cut recorded (MoveBackToReadyRefused) is
// over once a cut admits.
m.Reason = ""
@@ -1627,6 +1634,10 @@ const (
// RollbackCommitDeadline: the probe passed, but the commit would have
// come after the cut or landing deadline (§7.9).
RollbackCommitDeadline = "the commit came after the deadline"
+ // RollbackProbeUnpriced: the admission recorded no probe attempts for
+ // the landing (H10: MoveAdmit's CutAttempts is absent), so the probe
+ // cannot be bounded.
+ RollbackProbeUnpriced = "the cut recorded no probe attempts for this landing"
)
// MoveMarkRollback records on a landing record why the landing stopped
@@ -1665,6 +1676,8 @@ func MoveMarkRollback(g MoveGuard, reason string) func(*Record) error {
rec.Move.Reason = RollbackProbeAttempts
case RollbackCommitDeadline:
rec.Move.Reason = RollbackCommitDeadline
+ case RollbackProbeUnpriced:
+ rec.Move.Reason = RollbackProbeUnpriced
default:
return fmt.Errorf("provision: rollback reason %q has no recorded sentence", reason)
}
@@ -1922,6 +1935,8 @@ func MoveRollBack(g MoveGuard, rbID, reason string, now time.Time) func(*Record)
rb.Reason = RollbackProbeAttempts
case RollbackCommitDeadline:
rb.Reason = RollbackCommitDeadline
+ case RollbackProbeUnpriced:
+ rb.Reason = RollbackProbeUnpriced
case RollbackDeclaredLost:
rb.Reason = RollbackDeclaredLost
default:
@@ -1941,7 +1956,7 @@ func MoveRollBack(g MoveGuard, rbID, reason string, now time.Time) func(*Record)
func recordedRollbackReason(r string) bool {
switch r {
case RollbackConfigChanged, RollbackLandingDeadline, RollbackClaimWired, RollbackLandingLayout, RollbackInstallMismatch,
- RollbackFilesOccupied, RollbackProbeAttempts, RollbackCommitDeadline, RollbackDeclaredLost:
+ RollbackFilesOccupied, RollbackProbeAttempts, RollbackCommitDeadline, RollbackDeclaredLost, RollbackProbeUnpriced:
return true
}
return false