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>
4 files changed,
+101
−29
docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+1 −1internal/console/apiinstancemove.go+27 −14internal/console/apiinstancemove_test.go+72 −13internal/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{}} |
| } |