carlos / platform Public

moves: the door refuses a source that is not active or is fenced (Ruling E2)

From one roster observation the Phase 1 door requires the source's entry
to be active with an enrollment and to have no termination fence, else 409
(MV-N156 reworded). A fence that cannot be read is a 503. The test roster
now gives boxes a label different from their reference.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 909c6bd3f15651ef3760ca43bfdebde71e3bbbd5 parent 496fc29
4 files changed, +101 −29
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +1 −1
  • internal/console/apiinstancemove.go +27 −14
  • internal/console/apiinstancemove_test.go +72 −13
  • internal/console/boxselect_test.go +1 −1
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 92b12e788..011cea1fe 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -195,7 +195,7 @@ 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-N156 | internal/console/apiinstancemove.go | the source box is not eligible to move from | 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 |
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go
index 72aeacc6f..8135f45fd 100644
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
@@ -525,19 +525,32 @@ 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)
+ // Ruling E2. The claim's conversion is pinned to the box incarnation
+ // that starts the move, so, from ONE roster observation, the source
+ // must be an active roster entry with an enrollment (which is recorded
+ // on the op) and must carry no termination fence. Anything else is not
+ // a box to move from (fail closed). A roster or fence that cannot be
+ // read or trusted is a 503, with the error logged and never sent.
+ dir := hostactors.Directory{Store: s.cfg.Releases, PublicKey: s.cfg.ReleasePub, Bucket: s.cfg.Bucket}
+ roster, err := dir.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")
+ enrollment, eligible := sourceEnrollment(roster.Roster, obs.Claim.Op.Exec)
+ if !eligible {
+ jsonError(w, http.StatusConflict, "the source box is not eligible to move from")
+ return
+ }
+ fenced, err := dir.TerminationFence(ctx, roster, obs.Claim.Op.Exec, time.Now())
+ if err != nil {
+ log.Printf("console: api instance move %s/%s/%s: termination fence of %s: %v", acct.Sqid, app, host, obs.Claim.Op.Exec, err)
+ jsonError(w, http.StatusServiceUnavailable, "could not read the host roster. try again")
+ return
+ }
+ if fenced {
+ jsonError(w, http.StatusConflict, "the source box is not eligible to move from")
return
}
id, err := moves.NewMoveID(rand.Reader)
@@ -750,14 +763,14 @@ 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.
+// sourceEnrollment is the enrollment of the box that executes the claim, and
+// whether the roster lets a move start from it: an entry under that box
+// reference (never a label), in state "active", with a non-empty
+// enrollment. The termination fence is a separate check on the same
+// observation.
func sourceEnrollment(r hostactors.Roster, box string) (string, bool) {
b, ok := r.Boxes[box]
- if !ok || b.Enrollment == "" {
+ if !ok || b.State != "active" || b.Enrollment == "" {
return "", false
}
return b.Enrollment, true
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go
index a73962041..da1b4cec6 100644
--- a/internal/console/apiinstancemove_test.go
+++ b/internal/console/apiinstancemove_test.go
@@ -733,13 +733,49 @@ func TestMoveDoorPinsTheSourceEnrollment(t *testing.T) {
}
}
-// 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"
+// brokenFenceStore fails every read of a termination fence and nothing else.
+type brokenFenceStore struct{ release.Store }
+
+func (b brokenFenceStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if strings.HasPrefix(key, "control/box-fences/") {
+ return nil, "", errors.New("secret-detail: fence store down")
+ }
+ return b.Store.Get(ctx, key)
+}
+
+// writeMoveFence stores a termination fence for box, as its own regional
+// worker would have: observed terminated, for the roster's cloud identity.
+func writeMoveFence(t *testing.T, fx *consoleFixture, b hostactors.Box, body map[string]any) {
+ t.Helper()
+ f := map[string]any{"v": 1, "box": b.BoxID, "enrollment": b.Enrollment, "cloud": b.Cloud, "state": "terminated",
+ "observed_at": time.Now().UTC(), "via": "describe", "request_id": "request",
+ "written_by": map[string]string{"box": b.BoxID, "enrollment": b.Enrollment}}
+ for k, v := range body {
+ f[k] = v
+ }
+ raw, err := json.Marshal(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := release.CreateExact(context.Background(), fx.rel, "control/box-fences/"+b.Enrollment+".json", raw); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// Ruling E2: from ONE roster observation, a source must be active, carry an
+// enrollment, and have no termination fence, or the door refuses 409. A
+// roster or fence that cannot be read is a 503 with a fixed sentence, never
+// the error's own text. Every refusal writes neither the claim nor the
+// record.
+func TestMoveDoorRefusesAnIneligibleSource(t *testing.T) {
+ const ineligible = "the source box is not eligible to move from"
const unreadable = "could not read the host roster. try again"
ie, uk := ieBox("ie-box"), ukBox("uk-box")
+ rosterWith := func(mut func(b *hostactors.Box)) hostactors.Roster {
+ b := moveRosterBox(ie)
+ mut(&b)
+ return hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": b, "uk-box": moveRosterBox(uk)}}
+ }
for _, c := range []struct {
name string
setup func(t *testing.T, fx *consoleFixture)
@@ -748,10 +784,27 @@ func TestMoveDoorFailsClosedOnTheSourceEnrollment(t *testing.T) {
}{
{"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},
+ }, http.StatusConflict, ineligible},
{"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},
+ }, http.StatusConflict, ineligible},
+ {"a retired source", func(t *testing.T, fx *consoleFixture) {
+ putMoveRoster(t, fx, rosterWith(func(b *hostactors.Box) { b.State = "retired" }))
+ }, http.StatusConflict, ineligible},
+ {"a quarantined source", func(t *testing.T, fx *consoleFixture) {
+ putMoveRoster(t, fx, rosterWith(func(b *hostactors.Box) {
+ b.State, b.QuarantineRevision, b.LossIDs = "quarantined", 1, []string{"loss-1"}
+ }))
+ }, http.StatusConflict, ineligible},
+ {"a fenced source", func(t *testing.T, fx *consoleFixture) {
+ writeMoveFence(t, fx, moveRosterBox(ie), nil)
+ }, http.StatusConflict, ineligible},
+ {"a fence that cannot be read", func(t *testing.T, fx *consoleFixture) {
+ fx.srv.cfg.Releases = brokenFenceStore{fx.srv.cfg.Releases}
+ }, http.StatusServiceUnavailable, unreadable},
+ {"a fence record that does not validate", func(t *testing.T, fx *consoleFixture) {
+ writeMoveFence(t, fx, moveRosterBox(ie), map[string]any{"state": "stopped"})
+ }, http.StatusServiceUnavailable, unreadable},
{"no roster object", func(t *testing.T, fx *consoleFixture) {
if err := fx.rel.Delete(context.Background(), hostactors.Key); err != nil {
t.Fatal(err)
@@ -763,6 +816,9 @@ func TestMoveDoorFailsClosedOnTheSourceEnrollment(t *testing.T) {
t.Fatal(err)
}
}, http.StatusServiceUnavailable, unreadable},
+ {"a roster whose source has an empty enrollment", func(t *testing.T, fx *consoleFixture) {
+ putMoveRoster(t, fx, rosterWith(func(b *hostactors.Box) { b.Enrollment = "" }))
+ }, 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"
@@ -789,18 +845,21 @@ func TestMoveDoorFailsClosedOnTheSourceEnrollment(t *testing.T) {
}
}
-// 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.
+// The lookup the door uses: by box reference (never by label), active only,
+// with a non-empty enrollment. A verified roster cannot carry an empty
+// enrollment, so that case is proved here and answers 503 at the door.
func TestSourceEnrollmentOfTheRoster(t *testing.T) {
r := hostactors.Roster{Boxes: map[string]hostactors.Box{
- "ie-box": {BoxID: "ie-box", Enrollment: "e1"},
- "blank": {BoxID: "blank"},
+ "ie-box": {BoxID: "ie-box", Label: "dublin", Enrollment: "e1", State: "active"},
+ "blank": {BoxID: "blank", State: "active"},
+ "retired": {BoxID: "retired", Enrollment: "e2", State: "retired"},
+ "quar": {BoxID: "quar", Enrollment: "e3", State: "quarantined"},
+ "nostate": {BoxID: "nostate", Enrollment: "e4"},
}}
if got, ok := sourceEnrollment(r, "ie-box"); !ok || got != "e1" {
t.Fatalf("enrolled source = %q, %v", got, ok)
}
- for _, box := range []string{"blank", "absent", ""} {
+ for _, box := range []string{"blank", "retired", "quar", "nostate", "dublin", "absent", ""} {
if got, ok := sourceEnrollment(r, box); ok || got != "" {
t.Fatalf("source %q = %q, %v, want none", box, got, ok)
}
diff --git a/internal/console/boxselect_test.go b/internal/console/boxselect_test.go
index 20a76bf5f..0c3ee66d2 100644
--- a/internal/console/boxselect_test.go
+++ b/internal/console/boxselect_test.go
@@ -57,7 +57,7 @@ func putMoveRoster(t *testing.T, fx *consoleFixture, r hostactors.Roster) {
// 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",
+ return hostactors.Box{BoxID: b.Box, Namespace: "platform", Label: b.Box + "-label", 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{}}
}