carlos / platform Public

moves: the door pins the source box's roster enrollment on the claim op

BeginMove records Intent[source_enrollment] beside to, source and target.
The Phase 1 door reads the signed host roster at request time and refuses
a source with no enrollment (409, fail closed); an unreadable roster is a
503 with a fixed sentence. Copy rows MV-N156 and MV-N157 are pending.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 5aa9a94825d056e059d4870cc60094e9be36a6c7 parent 17362ea
8 files changed, +245 −22
  • approved_moves_copy_test.go +2 −1
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +3 −0
  • internal/console/apiinstancemove.go +30 −1
  • internal/console/apiinstancemove_test.go +125 −0
  • internal/console/boxselect_test.go +55 −0
  • internal/host/movesphase1_harness_test.go +1 −1
  • internal/hostclaim/move.go +9 −4
  • internal/hostclaim/move_test.go +20 −15
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 54633a34a..74df2e507 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -64,7 +64,8 @@ var movesCopyIDs = []string{
"MV-N133", "MV-N134", "MV-N135", "MV-N136", "MV-N137", "MV-N138",
"MV-N139", "MV-N140", "MV-N141", "MV-N142", "MV-N143", "MV-N144",
"MV-N145", "MV-N146", "MV-N147", "MV-N148", "MV-N149", "MV-N150",
- "MV-N151", "MV-N152", "MV-N153", "MV-N154", "MV-N155",
+ "MV-N151", "MV-N152", "MV-N153", "MV-N154", "MV-N155", "MV-N156",
+ "MV-N157", "MV-N158",
}
// copyRow is one row of the record. The File cell is the file the sentence is
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 bf18b48c6..92b12e788 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -195,6 +195,9 @@ Columns: id, file, text, status.
| MV-N153 | internal/host/movesreport.go | this box requires a proof for the source's release and has not accepted one | pending |
| MV-N154 | internal/host/movesreport.go | the source staged its archive without a fixed version id | pending |
| MV-N155 | internal/moves/readiness.go | T11: the database has grown to <MiB> MiB since the target priced <MiB> MiB. waiting for the target's next report | pending |
+| MV-N156 | internal/console/apiinstancemove.go | the source box is not enrolled | pending |
+| MV-N157 | internal/console/apiinstancemove.go | could not read the host roster. try again | pending |
+| MV-N158 | internal/provision/provision.go | stop evidence budget | pending |
## Plan 2
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go
index cd52673fe..72aeacc6f 100644
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
@@ -12,6 +12,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/hostactors"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/objstore"
@@ -524,6 +525,21 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req
jsonError(w, http.StatusConflict, "the source box "+obs.Claim.Op.Exec+" is not the eligible box of "+from)
return
}
+ // The claim's conversion is pinned to the box incarnation that starts
+ // the move, so the source's enrollment is read now, from the signed
+ // roster, and recorded on the op. A source the roster does not vouch
+ // for does not move (fail closed); an unreadable roster is a 503.
+ roster, err := (hostactors.Directory{Store: s.cfg.Releases, PublicKey: s.cfg.ReleasePub, Bucket: s.cfg.Bucket}).Read(ctx)
+ if err != nil {
+ log.Printf("console: api instance move %s/%s/%s: host roster: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusServiceUnavailable, "could not read the host roster. try again")
+ return
+ }
+ enrollment, enrolled := sourceEnrollment(roster.Roster, obs.Claim.Op.Exec)
+ if !enrolled {
+ jsonError(w, http.StatusConflict, "the source box is not enrolled")
+ return
+ }
id, err := moves.NewMoveID(rand.Reader)
if err != nil {
log.Printf("console: api instance move %s/%s/%s: move id: %v", acct.Sqid, app, host, err)
@@ -542,7 +558,7 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req
return
}
exec := obs.Claim.Op.Exec
- moving, err := hostclaim.Update(ctx, s.cfg.Releases, obs, hostclaim.BeginMove(id, addr, exec, sel.Target, to, now))
+ moving, err := hostclaim.Update(ctx, s.cfg.Releases, obs, hostclaim.BeginMove(id, addr, exec, enrollment, sel.Target, to, now))
if err != nil {
switch {
case errors.Is(err, hostclaim.ErrPendingChore):
@@ -733,3 +749,16 @@ func phase1Conflict(err error) string {
}
return "this move's state does not allow that. read its state and try again"
}
+
+// sourceEnrollment is the roster's enrollment for the box that executes the
+// claim. The roster is keyed by box reference, which is what the claim's
+// executor and the edges files name. No entry, or an entry with an empty
+// enrollment, is no enrollment: the move must not start on a box the
+// roster does not vouch for.
+func sourceEnrollment(r hostactors.Roster, box string) (string, bool) {
+ b, ok := r.Boxes[box]
+ if !ok || b.Enrollment == "" {
+ return "", false
+ }
+ return b.Enrollment, true
+}
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go
index cce47d64d..a73962041 100644
--- a/internal/console/apiinstancemove_test.go
+++ b/internal/console/apiinstancemove_test.go
@@ -6,11 +6,13 @@ import (
"errors"
"fmt"
"net/http"
+ "reflect"
"strings"
"testing"
"time"
+ "github.com/carlosframework/platform/internal/hostactors"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/objstore"
@@ -691,6 +693,129 @@ func TestMoveDoorBindsTheClaimToTheSourceAndTheIncarnation(t *testing.T) {
}
}
+// moveDoorWithClaim is a Phase 1 door fixture that has everything but the
+// request: the instance in ie, the fleet armed (and enrolled in the roster),
+// and a serving claim under ie-box. It returns the move URL.
+func moveDoorWithClaim(t *testing.T, code string) (fx *consoleFixture, token, moveURL string) {
+ t.Helper()
+ fx, token = operatorInstanceFixture(t, code)
+ m := titoManifest()
+ m.Regions = nil
+ m.Pool = ""
+ enableInstances(t, fx, "titogo", m)
+ u := instancesURL(fx, "bab", "titogo")
+ if c, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); c != http.StatusCreated {
+ t.Fatalf("create = %d %s", c, out)
+ }
+ armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box"))
+ claimFor(t, fx, "acme.go.tito.io", "ie-box", "ie")
+ return fx, token, u + "/acme.go.tito.io/move"
+}
+
+// The door records the source box's roster enrollment on the claim's op, so
+// the claim's conversion is pinned to the box incarnation that started the
+// move. The keys the op always carried are unchanged.
+func TestMoveDoorPinsTheSourceEnrollment(t *testing.T) {
+ fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-enr")
+ if c, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`); c != http.StatusAccepted {
+ t.Fatalf("request = %d %s", c, out)
+ }
+ obs, err := hostclaim.Read(context.Background(), fx.rel, "acme.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := map[string]string{"to": "uk", "source": "ie-box", "target": "uk-box", "source_enrollment": moveTestEnrollment("ie-box")}
+ if !reflect.DeepEqual(obs.Claim.Op.Intent, want) {
+ t.Fatalf("claim intent = %v, want %v", obs.Claim.Op.Intent, want)
+ }
+ if moveTestEnrollment("ie-box") == moveTestEnrollment("uk-box") {
+ t.Fatal("the fixture must give the two boxes different enrollments")
+ }
+}
+
+// A source with no roster entry is refused, closed: 409, and neither the
+// claim nor the record is written. A roster that cannot be read is a 503
+// with a fixed sentence, never the error's own text.
+func TestMoveDoorFailsClosedOnTheSourceEnrollment(t *testing.T) {
+ const notEnrolled = "the source box is not enrolled"
+ const unreadable = "could not read the host roster. try again"
+ ie, uk := ieBox("ie-box"), ukBox("uk-box")
+ for _, c := range []struct {
+ name string
+ setup func(t *testing.T, fx *consoleFixture)
+ code int
+ want string
+ }{
+ {"no roster entry for the source", func(t *testing.T, fx *consoleFixture) {
+ putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"uk-box": moveRosterBox(uk)}})
+ }, http.StatusConflict, notEnrolled},
+ {"an entry for another box only", func(t *testing.T, fx *consoleFixture) {
+ putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"old-box": moveRosterBox(fleetBox{Box: "old-box", Placement: "ie", AWS: "eu-west-1"})}})
+ }, http.StatusConflict, notEnrolled},
+ {"no roster object", func(t *testing.T, fx *consoleFixture) {
+ if err := fx.rel.Delete(context.Background(), hostactors.Key); err != nil {
+ t.Fatal(err)
+ }
+ }, http.StatusServiceUnavailable, unreadable},
+ {"a roster that does not verify", func(t *testing.T, fx *consoleFixture) {
+ putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": moveRosterBox(ie)}})
+ if _, err := fx.rel.Put(context.Background(), hostactors.Key, []byte(`{"secret-detail":"ed25519: bad"}`), mustETag(t, fx, hostactors.Key)); err != nil {
+ t.Fatal(err)
+ }
+ }, http.StatusServiceUnavailable, unreadable},
+ {"a roster for another bucket", func(t *testing.T, fx *consoleFixture) {
+ putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": moveRosterBox(ie)}})
+ fx.srv.cfg.Bucket = "another-bucket"
+ }, http.StatusServiceUnavailable, unreadable},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-enr-closed")
+ c.setup(t, fx)
+ before := claimBytes(t, fx, "acme.go.tito.io")
+ code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`)
+ if code != c.code || errorText(out) != c.want {
+ t.Fatalf("request = %d %s, want %d %q", code, out, c.code, c.want)
+ }
+ if strings.Contains(out, "secret-detail") || strings.Contains(out, "roster authority") {
+ t.Fatalf("the body leaks an error's text: %s", out)
+ }
+ if claimBytes(t, fx, "acme.go.tito.io") != before {
+ t.Fatal("a refused request wrote the claim")
+ }
+ if rec, _ := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io"); rec.Move != nil {
+ t.Fatalf("a refused request wrote the record: %+v", rec.Move)
+ }
+ })
+ }
+}
+
+// An entry whose enrollment is empty is as good as none. A verified roster
+// cannot carry one (its validation refuses it), so the check is proved on
+// the lookup itself.
+func TestSourceEnrollmentOfTheRoster(t *testing.T) {
+ r := hostactors.Roster{Boxes: map[string]hostactors.Box{
+ "ie-box": {BoxID: "ie-box", Enrollment: "e1"},
+ "blank": {BoxID: "blank"},
+ }}
+ if got, ok := sourceEnrollment(r, "ie-box"); !ok || got != "e1" {
+ t.Fatalf("enrolled source = %q, %v", got, ok)
+ }
+ for _, box := range []string{"blank", "absent", ""} {
+ if got, ok := sourceEnrollment(r, box); ok || got != "" {
+ t.Fatalf("source %q = %q, %v, want none", box, got, ok)
+ }
+ }
+}
+
+func mustETag(t *testing.T, fx *consoleFixture, key string) string {
+ t.Helper()
+ _, etag, err := fx.rel.Get(context.Background(), key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return etag
+}
+
// Private storage is the instance's BINDING (round-4 finding 13), not the
// app's create-time policy, and both policy reads fail closed.
func TestMoveDoorRefusesABoundStoreNotAPolicy(t *testing.T) {
diff --git a/internal/console/boxselect_test.go b/internal/console/boxselect_test.go
index b02039cf7..20a76bf5f 100644
--- a/internal/console/boxselect_test.go
+++ b/internal/console/boxselect_test.go
@@ -2,17 +2,66 @@ package console
import (
"context"
+ "crypto/sha256"
+ "encoding/hex"
"encoding/json"
"errors"
+ "strings"
"testing"
"time"
"github.com/carlosframework/platform/internal/boxid"
"github.com/carlosframework/platform/internal/boxid/boxidtest"
+ "github.com/carlosframework/platform/internal/hostactors"
"github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
)
+// moveTestBucket is the deployment bucket the roster armMoveFleet publishes
+// names, and the console is configured with.
+const moveTestBucket = "move-test-bucket"
+
+// moveTestEnrollment is the enrollment the test roster gives a box: 32 hex
+// characters, distinct per box.
+func moveTestEnrollment(box string) string {
+ sum := sha256.Sum256([]byte(box))
+ return hex.EncodeToString(sum[:16])
+}
+
+// putMoveRoster signs r with the fixture's root key and stores it as the
+// host roster, replacing any earlier one, and points the console at its
+// bucket.
+func putMoveRoster(t *testing.T, fx *consoleFixture, r hostactors.Roster) {
+ t.Helper()
+ ctx := context.Background()
+ fx.srv.cfg.Bucket = moveTestBucket
+ r.V, r.DeploymentBucket, r.Revision = 1, moveTestBucket, 1
+ if r.Boxes == nil {
+ r.Boxes = map[string]hostactors.Box{}
+ }
+ r.Consoles, r.Holds, r.Activations = map[string]hostactors.Console{}, map[string]hostactors.Reservation{}, map[string]hostactors.Reservation{}
+ raw, err := release.SignRootEnvelope(release.RootSigner(fx.priv), r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, etag, err := fx.rel.Get(ctx, hostactors.Key)
+ if err != nil {
+ etag = ""
+ }
+ if _, err := fx.rel.Put(ctx, hostactors.Key, raw, etag); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// moveRosterBox is the roster entry of a fleet box: active, with the
+// enrollment moveTestEnrollment gives it.
+func moveRosterBox(b fleetBox) hostactors.Box {
+ return hostactors.Box{BoxID: b.Box, Namespace: "platform", Label: b.Box, Region: b.Placement, Pool: "default",
+ Cloud: hostactors.Cloud{Provider: "aws", Account: moveTestAccount, Region: b.AWS, InstanceID: "i-" + b.Box},
+ Enrollment: moveTestEnrollment(b.Box), EvidenceSHA256: strings.Repeat("f", 64), State: "active", LossIDs: []string{}}
+}
+
// moveTestAccount is the AWS account armMoveFleet's boxes are signed in and
// the console is configured to trust.
const moveTestAccount = "111122223333"
@@ -97,6 +146,12 @@ func armMoveFleet(t *testing.T, fx *consoleFixture, boxes ...fleetBox) *boxidtes
t.Fatal(err)
}
}
+ // Every box is enrolled in the roster the door reads.
+ roster := hostactors.Roster{Boxes: map[string]hostactors.Box{}}
+ for _, b := range boxes {
+ roster.Boxes[b.Box] = moveRosterBox(b)
+ }
+ putMoveRoster(t, fx, roster)
return signer
}
diff --git a/internal/host/movesphase1_harness_test.go b/internal/host/movesphase1_harness_test.go
index 97f5d1f94..97c20ce21 100644
--- a/internal/host/movesphase1_harness_test.go
+++ b/internal/host/movesphase1_harness_test.go
@@ -447,7 +447,7 @@ func (h *phase1Harness) start(t *testing.T, cut string) provision.Record {
if err != nil {
t.Fatal(err)
}
- if _, err := hostclaim.Update(ctx, h.s, obs, hostclaim.BeginMove(p1ID, "op", "ie-box", "uk-box", "uk", h.clock.Now())); err != nil {
+ if _, err := hostclaim.Update(ctx, h.s, obs, hostclaim.BeginMove(p1ID, "op", "ie-box", "enr-ie", "uk-box", "uk", h.clock.Now())); err != nil {
t.Fatal(err)
}
return h.read(t, p1Host)
diff --git a/internal/hostclaim/move.go b/internal/hostclaim/move.go
index 44fdd2fe9..1f5c78fdd 100644
--- a/internal/hostclaim/move.go
+++ b/internal/hostclaim/move.go
@@ -24,20 +24,25 @@ const journalReleasedMove = "released_move"
// has every write site add its own region or box first (Touch). Every
// other claim field (holder, admissions, generation, chores) is left as
// found.
-func BeginMove(opID, by, sourceBox, targetBox, targetRegion string, now time.Time) func(*Claim) error {
+//
+// sourceEnrollment is the source box's roster enrollment at the moment the
+// door starts the move. It is recorded as Intent["source_enrollment"] so the
+// claim's conversion of a moving op is pinned to the box incarnation that
+// began it. The other intent keys (to, source, target) are unchanged.
+func BeginMove(opID, by, sourceBox, sourceEnrollment, targetBox, targetRegion string, now time.Time) func(*Claim) error {
return func(c *Claim) error {
if err := StartGuard(c, []string{targetRegion}, []string{targetBox}); err != nil {
return err
}
- if opID == "" || sourceBox == "" || targetBox == "" || targetRegion == "" {
- return fmt.Errorf("%w: a move needs an op id, two boxes and a target region", ErrMalformed)
+ if opID == "" || sourceBox == "" || sourceEnrollment == "" || targetBox == "" || targetRegion == "" {
+ return fmt.Errorf("%w: a move needs an op id, two boxes, the source's enrollment and a target region", ErrMalformed)
}
if c.Op.Exec != sourceBox {
return fmt.Errorf("%w: exec is %q, the move's source is %q", ErrWrongState, c.Op.Exec, sourceBox)
}
c.Op = Op{Kind: KindMoving, ID: opID, Phase: PhaseMoving, By: by, StartedAt: now.UTC(), Exec: sourceBox,
Participants: []string{sourceBox, targetBox},
- Intent: map[string]string{"to": targetRegion, "source": sourceBox, "target": targetBox}}
+ Intent: map[string]string{"to": targetRegion, "source": sourceBox, "target": targetBox, "source_enrollment": sourceEnrollment}}
return nil
}
}
diff --git a/internal/hostclaim/move_test.go b/internal/hostclaim/move_test.go
index 65614ae2e..c796fd79f 100644
--- a/internal/hostclaim/move_test.go
+++ b/internal/hostclaim/move_test.go
@@ -38,12 +38,17 @@ func movingTestClaim(t *testing.T) *Claim {
func TestBeginMoveFromServingOnly(t *testing.T) {
now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
c := movingTestClaim(t)
- if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(c); err != nil {
+ if err := BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", now)(c); err != nil {
t.Fatal(err)
}
if c.Op.Kind != KindMoving || c.Op.ID != moveA || c.Op.Exec != "ie-box" || c.Op.By != "ops" || !c.Op.StartedAt.Equal(now) || len(c.Op.Participants) != 2 || c.Op.Participants[0] != "ie-box" || c.Op.Participants[1] != "uk-box" || c.Op.Intent["to"] != "uk" {
t.Fatalf("op after BeginMove: %+v", c.Op)
}
+ // The intent carries the source box's enrollment (the claim's C7
+ // conversion pins to it) beside the three keys that were always there.
+ if want := map[string]string{"to": "uk", "source": "ie-box", "target": "uk-box", "source_enrollment": "enr-ie"}; !reflect.DeepEqual(c.Op.Intent, want) {
+ t.Fatalf("intent after BeginMove = %v, want %v", c.Op.Intent, want)
+ }
if c.Op.Phase != PhaseMoving {
t.Fatalf("phase after BeginMove: %q", c.Op.Phase)
}
@@ -56,36 +61,36 @@ func TestBeginMoveFromServingOnly(t *testing.T) {
t.Fatalf("touched grew at BeginMove: %+v", c.Touched)
}
// Not from serving.
- if err := BeginMove(moveB, "ops", "ie-box", "uk-box", "uk", now)(c); !errors.Is(err, ErrWrongState) {
+ if err := BeginMove(moveB, "ops", "ie-box", "enr-ie", "uk-box", "uk", now)(c); !errors.Is(err, ErrWrongState) {
t.Fatalf("second move: %v", err)
}
// exec must be the source.
d := movingTestClaim(t)
- if err := BeginMove(moveA, "ops", "us-box", "uk-box", "uk", now)(d); !errors.Is(err, ErrWrongState) {
+ if err := BeginMove(moveA, "ops", "us-box", "enr-us", "uk-box", "uk", now)(d); !errors.Is(err, ErrWrongState) {
t.Fatalf("source not exec: %v", err)
}
// A pending chore in the target region refuses (CC11).
e := movingTestClaim(t)
e.Obligations = []Obligation{{ID: "o1", Op: "x", Kind: "park-replica", Region: "uk", Retirement: "r", Owner: "region:uk"}}
- if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(e); !errors.Is(err, ErrPendingChore) {
+ if err := BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", now)(e); !errors.Is(err, ErrPendingChore) {
t.Fatalf("pending chore: %v", err)
}
// ... and in the target box.
f := movingTestClaim(t)
f.Obligations = []Obligation{{ID: "o1", Op: "x", Kind: "park-local", Box: "uk-box", Retirement: "r", Owner: "box:uk-box"}}
- if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(f); !errors.Is(err, ErrPendingChore) {
+ if err := BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", now)(f); !errors.Is(err, ErrPendingChore) {
t.Fatalf("pending box chore: %v", err)
}
// A claim with no holder is not movable.
g := movingTestClaim(t)
g.Holder = nil
- if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(g); !errors.Is(err, ErrWrongState) {
+ if err := BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", now)(g); !errors.Is(err, ErrWrongState) {
t.Fatalf("no holder: %v", err)
}
// Missing inputs are malformed.
- for _, args := range [][4]string{{"", "ie-box", "uk-box", "uk"}, {moveA, "", "uk-box", "uk"}, {moveA, "ie-box", "", "uk"}, {moveA, "ie-box", "uk-box", ""}} {
+ for _, args := range [][5]string{{"", "ie-box", "enr-ie", "uk-box", "uk"}, {moveA, "", "enr-ie", "uk-box", "uk"}, {moveA, "ie-box", "", "uk-box", "uk"}, {moveA, "ie-box", "enr-ie", "", "uk"}, {moveA, "ie-box", "enr-ie", "uk-box", ""}} {
h := movingTestClaim(t)
- if err := BeginMove(args[0], "ops", args[1], args[2], args[3], now)(h); !errors.Is(err, ErrMalformed) {
+ if err := BeginMove(args[0], "ops", args[1], args[2], args[3], args[4], now)(h); !errors.Is(err, ErrMalformed) {
t.Fatalf("args %v: %v", args, err)
}
if h.Op.Kind != KindServing {
@@ -96,7 +101,7 @@ func TestBeginMoveFromServingOnly(t *testing.T) {
func TestTouchAppendsOnceUnderTheOp(t *testing.T) {
c := movingTestClaim(t)
- if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", time.Now())(c); err != nil {
+ if err := BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", time.Now())(c); err != nil {
t.Fatal(err)
}
if err := Touch(moveA, []string{"uk"}, nil)(c); err != nil {
@@ -139,7 +144,7 @@ func TestReleaseMoveFromMovingNeedsNoChores(t *testing.T) {
now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
for _, phase := range []string{PhaseMoving, PhaseAborting} {
c := movingTestClaim(t)
- if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(c); err != nil {
+ if err := BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", now)(c); err != nil {
t.Fatal(err)
}
c.Op.Phase = phase
@@ -172,21 +177,21 @@ func TestReleaseMoveFromMovingNeedsNoChores(t *testing.T) {
t.Fatalf("release from serving: %v", err)
}
m := movingTestClaim(t)
- _ = BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(m)
+ _ = BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", now)(m)
m.Op.Phase = PhaseSealed
if err := ReleaseMove(moveA, "ie-box")(m); !errors.Is(err, ErrWrongState) {
t.Fatalf("release from sealed: %v", err)
}
// Another kind under the same id is not a move.
l := movingTestClaim(t)
- _ = BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(l)
+ _ = BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", now)(l)
l.Op.Kind = KindLanding
if err := ReleaseMove(moveA, "ie-box")(l); !errors.Is(err, ErrWrongState) {
t.Fatalf("release from landing: %v", err)
}
// The executor is named.
e := movingTestClaim(t)
- _ = BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(e)
+ _ = BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", now)(e)
if err := ReleaseMove(moveA, "")(e); !errors.Is(err, ErrMalformed) {
t.Fatalf("release without exec: %v", err)
}
@@ -199,7 +204,7 @@ func TestReleaseMoveLeavesEvidence(t *testing.T) {
t.Fatalf("a plain serving claim satisfies the replay rule: %v", err)
}
c := movingTestClaim(t)
- if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(c); err != nil {
+ if err := BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", now)(c); err != nil {
t.Fatal(err)
}
if err := RequireReleasedMove(moveA)(c); !errors.Is(err, ErrStaleOp) {
@@ -245,7 +250,7 @@ func TestMoveRoundTripPreservesTheRestOfTheClaim(t *testing.T) {
t.Fatalf("fixture: %v", err)
}
before := mustJSON(t, c)
- if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(c); err != nil {
+ if err := BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", now)(c); err != nil {
t.Fatal(err)
}
if err := ReleaseMove(moveA, "ie-box")(c); err != nil {