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>
8 files changed,
+245
−22
approved_moves_copy_test.go+2 −1docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+3 −0internal/console/apiinstancemove.go+30 −1internal/console/apiinstancemove_test.go+125 −0internal/console/boxselect_test.go+55 −0internal/host/movesphase1_harness_test.go+1 −1internal/hostclaim/move.go+9 −4internal/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 { |