carlos / platform Public

console: the host-claim move door checks the roster before it registers, and a later refusal no longer blames the roster (E-switch fix round 1)

Review minor (apiinstancemove.go:691): BeginClaimMove answers ErrIdentity
for roster failures and for record races (incarnation, a move already
journaled) alike. The door now checks its own roster observation (the one
E2 read) against BeginClaimMove's roster precondition before RegisterMove:
both boxes active and enrolled, the source in the record's region, the
target in to, no termination fence on the target. A roster that fails is
MV-N177 and writes no registration. An ErrIdentity or ErrRoster from
BeginClaimMove itself is now the neutral MV-N178 ("the instance or its
boxes changed under this request. read its state and try again").

Review important (the compensation leaves the move-start effect uncertain)
is NOT fixed here: hostops exposes no API that resolves an uncertain
record effect. TestMoveDoorHostClaimRetryAfterAFailedRecordStart records
the requirement (fail-once Put, retry succeeds, registration drains) and
is skipped as a KNOWN GAP until the claim code provides that API.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 26d9e4d175e9823e36068a1f9bde491eab937e59 parent 0ea46af
4 files changed, +152 −14
  • approved_moves_copy_test.go +1 −1
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +2 −1
  • internal/console/apiinstancemove.go +40 −7
  • internal/console/apiinstancemove_claim_test.go +109 −5
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 6e0229d6a..6756eef9e 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -69,7 +69,7 @@ var movesCopyIDs = []string{
"MV-N157", "MV-N158", "MV-N159", "MV-N160", "MV-N161", "MV-N162",
"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-N175", "MV-N176", "MV-N177", "MV-N178",
}
// 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 6b56cb561..f35c797c2 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -210,7 +210,7 @@ Columns: id, file, text, status.
## Plan 2
-(Task 10 rows, then task E-switch's MV-N173 to MV-N177; the rest appended by Plan 2 Task 11)
+(Task 10 rows, then task E-switch's MV-N173 to MV-N178; the rest appended by Plan 2 Task 11)
| id | file | text | status |
|---|---|---|---|
@@ -224,6 +224,7 @@ Columns: id, file, text, status.
| MV-N175 | internal/console/apiinstancemove.go | another operation holds this app. try again when it finishes | pending |
| MV-N176 | internal/console/apiinstancemove.go | could not register the move. try again | pending |
| MV-N177 | internal/console/apiinstancemove.go | the host roster does not allow this move. the source and target must be active boxes in <from> and <to> | pending |
+| MV-N178 | internal/console/apiinstancemove.go | the instance or its boxes changed under this request. read its state and try again | pending |
## Plan 3
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go
index 40ba5d3c2..b1c5b21c9 100644
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
@@ -614,7 +614,7 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req
}
exec := obs.Claim.Op.Exec
if s.cfg.HostClaim {
- s.startClaimModeMove(w, r, acct, app, host, obs, ms, sel.Target, from)
+ s.startClaimModeMove(w, r, acct, app, host, obs, ms, sel.Target, from, rec.Region, dir, roster)
return
}
moving, err := hostclaim.Update(ctx, s.cfg.Releases, obs, hostclaim.BeginMove(id, addr, exec, sel.Target, to, now))
@@ -656,14 +656,36 @@ func moveClaimCASFailed(w http.ResponseWriter, acct *Account, app, host, to stri
// and StartClaimMoveRecord journals the move on the record as one effect
// of that registration. The door never builds an authority of its own.
//
-// Every refusal before the claim CAS writes nothing but the registration,
-// which the admission sweep settles once its TTL passes. A failed record
-// start after the CAS is settled as on the plain path, with the release
-// written under the registration's lifecycle grant.
-func (s *Server) startClaimModeMove(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, obs hostclaim.Observed, ms provision.MoveStart, target, from string) {
+// Before anything is registered, the door's own roster observation (the
+// one E2 read) must place the move as BeginClaimMove will require: both
+// boxes active and enrolled, the source in the record's region, the target
+// in to, and no termination fence on the target. A roster that does not is
+// MV-N177. BeginClaimMove rereads the roster and the record; if it still
+// refuses (ErrIdentity, ErrRoster), something changed under the request
+// and the door does not guess what (MV-N178).
+//
+// Every refusal after RegisterMove and before the claim CAS writes nothing
+// but the registration, which the admission sweep settles once its TTL
+// passes. A failed record start after the CAS is settled as on the plain
+// path, with the release written under the registration's lifecycle grant.
+func (s *Server) startClaimModeMove(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, obs hostclaim.Observed, ms provision.MoveStart, target, from, recRegion string, dir hostactors.Directory, roster hostactors.Observed) {
ctx := r.Context()
a := s.cfg.ClaimAuthority
to := ms.To
+ if !claimMoveRosterPlaces(roster.Roster, obs.Claim.Op.Exec, target, recRegion, to) {
+ jsonError(w, http.StatusConflict, "the host roster does not allow this move. the source and target must be active boxes in "+from+" and "+to)
+ return
+ }
+ fenced, err := dir.TerminationFence(ctx, roster, target, time.Now())
+ if err != nil {
+ log.Printf("console: api instance move %s/%s/%s: termination fence of %s: %v", acct.Sqid, app, host, target, err)
+ jsonError(w, http.StatusServiceUnavailable, "could not read the host roster. try again")
+ return
+ }
+ if fenced {
+ jsonError(w, http.StatusConflict, "the host roster does not allow this move. the source and target must be active boxes in "+from+" and "+to)
+ return
+ }
appID, err := s.store.ClaimAppID(acct.ID, app)
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, hostops.ErrIdentity) {
jsonError(w, http.StatusConflict, "this app has no host-claim admission yet. try again shortly")
@@ -692,7 +714,7 @@ func (s *Server) startClaimModeMove(w http.ResponseWriter, r *http.Request, acct
switch {
case errors.Is(err, hostops.ErrIdentity), errors.Is(err, hostactors.ErrRoster):
log.Printf("console: api instance move %s/%s/%s: begin move %s: %v", acct.Sqid, app, host, ms.ID, err)
- jsonError(w, http.StatusConflict, "the host roster does not allow this move. the source and target must be active boxes in "+from+" and "+to)
+ jsonError(w, http.StatusConflict, "the instance or its boxes changed under this request. read its state and try again")
case errors.Is(err, hostops.ErrPending):
jsonError(w, http.StatusConflict, "another operation holds this app. try again when it finishes")
default:
@@ -884,6 +906,17 @@ func phase1Conflict(err error) string {
return "this move's state does not allow that. read its state and try again"
}
+// claimMoveRosterPlaces is BeginClaimMove's roster precondition, read from
+// the door's own observation: both boxes have active, enrolled entries
+// under their box references, they differ, the source is in the record's
+// region and the target in to, which is not the source's region.
+func claimMoveRosterPlaces(r hostactors.Roster, source, target, recRegion, to string) bool {
+ src, sourceOK := r.Boxes[source]
+ dst, targetOK := r.Boxes[target]
+ return sourceOK && targetOK && source != target && src.State == "active" && dst.State == "active" && src.Enrollment != "" && dst.Enrollment != "" &&
+ src.Region != "" && src.Region == recRegion && dst.Region == to && src.Region != to
+}
+
// 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
diff --git a/internal/console/apiinstancemove_claim_test.go b/internal/console/apiinstancemove_claim_test.go
index d27eb4482..30b76319a 100644
--- a/internal/console/apiinstancemove_claim_test.go
+++ b/internal/console/apiinstancemove_claim_test.go
@@ -278,21 +278,44 @@ func TestMoveDoorStartIsWhatC7Converts(t *testing.T) {
}
}
-// failRecordPut refuses every write of one record key; reads pass.
+// failRecordPut refuses writes of one record key; reads pass. With fails
+// > 0 it refuses that many writes and then passes; 0 refuses every write.
type failRecordPut struct {
release.Store
- key string
+ key string
+ fails *int
}
func (f failRecordPut) Put(ctx context.Context, key string, body []byte, match string) (string, error) {
- if key == f.key {
+ if key == f.key && (f.fails == nil || *f.fails > 0) {
+ if f.fails != nil {
+ *f.fails--
+ }
return "", errors.New("secret-detail: record put failed")
}
return f.Store.Put(ctx, key, body, match)
}
+// hookFirstGet runs hook once, on the first read of key, before the read.
+type hookFirstGet struct {
+ release.Store
+ key string
+ hook *func()
+}
+
+func (h hookFirstGet) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == h.key && *h.hook != nil {
+ f := *h.hook
+ *h.hook = nil
+ f()
+ }
+ return h.Store.Get(ctx, key)
+}
+
// Every host-claim refusal answers its recorded sentence before the claim
-// CAS: the claim and the record are as they were.
+// CAS: the claim and the record are as they were. A roster that does not
+// place both boxes is refused from the door's own roster observation, before
+// any registration is written.
func TestMoveDoorHostClaimRefusals(t *testing.T) {
const roster = "the host roster does not allow this move. the source and target must be active boxes in ie and uk"
for _, tc := range []struct {
@@ -301,6 +324,7 @@ func TestMoveDoorHostClaimRefusals(t *testing.T) {
setup func(t *testing.T, fx *consoleFixture, w *claimMoveWorld)
status int
msg string
+ registered bool // the refusal comes after RegisterMove
}{
{name: "app-not-bound", admission: true, status: http.StatusConflict, msg: "this app has no host-claim admission yet. try again shortly"},
{name: "no-admission", bind: true, status: http.StatusConflict, msg: "this app has no host-claim admission yet. try again shortly"},
@@ -321,10 +345,38 @@ func TestMoveDoorHostClaimRefusals(t *testing.T) {
setup: func(t *testing.T, fx *consoleFixture, _ *claimMoveWorld) {
putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": moveRosterBox(ieBox("ie-box"))}})
}},
+ {name: "target-in-another-region", bind: true, admission: true, status: http.StatusConflict, msg: roster,
+ setup: func(t *testing.T, fx *consoleFixture, _ *claimMoveWorld) {
+ uk := moveRosterBox(ukBox("uk-box"))
+ uk.Region = "fr"
+ putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": moveRosterBox(ieBox("ie-box")), "uk-box": uk}})
+ }},
+ {name: "target-fenced", bind: true, admission: true, status: http.StatusConflict, msg: roster,
+ setup: func(t *testing.T, fx *consoleFixture, _ *claimMoveWorld) {
+ writeMoveFence(t, fx, moveRosterBox(ukBox("uk-box")), nil)
+ }},
+ // The door's roster observation placed both boxes; the roster changed
+ // before BeginClaimMove's own read (here: the target quarantined while
+ // the move registered). The refusal does not guess which part of the
+ // world changed.
+ {name: "changed-under-the-request", bind: true, admission: true, status: http.StatusConflict, registered: true,
+ msg: "the instance or its boxes changed under this request. read its state and try again"},
} {
t.Run(tc.name, func(t *testing.T) {
fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-refuse")
- w := armClaimMoves(t, fx, tc.bind, tc.admission, nil)
+ var raw release.Store
+ if tc.name == "changed-under-the-request" {
+ var hook func()
+ hook = func() {
+ uk := moveRosterBox(ukBox("uk-box"))
+ uk.State, uk.QuarantineRevision, uk.LossIDs = "quarantined", 1, []string{"loss"}
+ putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": moveRosterBox(ieBox("ie-box")), "uk-box": uk}})
+ }
+ // RegisterMove reads the admission through the data handle,
+ // after the door's own roster checks and before BeginClaimMove.
+ raw = hookFirstGet{Store: fx.rel, key: "apps/bab/titogo/admission.json", hook: &hook}
+ }
+ w := armClaimMoves(t, fx, tc.bind, tc.admission, raw)
if tc.setup != nil {
tc.setup(t, fx, w)
}
@@ -343,6 +395,16 @@ func TestMoveDoorHostClaimRefusals(t *testing.T) {
if rec.Move != nil {
t.Fatalf("a refused request journaled a move: %+v", rec.Move)
}
+ if !tc.admission {
+ return
+ }
+ a, err := hostops.ReadAdmission(context.Background(), fx.rel, "bab", "titogo", claimMoveAppID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := len(a.Value.Registrations) > 0; got != tc.registered {
+ t.Fatalf("registrations after the refusal = %+v, want registered=%v", a.Value.Registrations, tc.registered)
+ }
})
}
}
@@ -386,3 +448,45 @@ func TestMoveDoorHostClaimFailedRecordStartReleasesTheClaim(t *testing.T) {
t.Fatalf("record move = %+v", rec.Move)
}
}
+
+// The failed record start's "try again" must be true: after the storage
+// failure clears, a retry starts the move, and once the moves settle and the
+// registrations pass their TTL, the failed attempt's registration drains
+// (its effect does not stay unsettled).
+//
+// KNOWN GAP (task E-switch fix round 1, NEEDS_CONTEXT): this fails today. The
+// failed attempt's move-start effect is left "uncertain" (the Put was issued),
+// the retry's effect on the same record is refused by prepareEffect, and the
+// registration cannot drain while its effect is unsettled. hostops has no
+// API that resolves an uncertain record effect; the claim code owns that.
+// Remove the skip when it lands and the compensation calls it.
+func TestMoveDoorHostClaimRetryAfterAFailedRecordStart(t *testing.T) {
+ t.Skip("KNOWN GAP: no hostops API resolves an uncertain move-start effect (task E-switch fix round 1)")
+ ctx := context.Background()
+ fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-retry")
+ fails := 1
+ w := armClaimMoves(t, fx, true, true, failRecordPut{Store: fx.rel, key: provision.RecordKey("bab", "titogo", "acme.go.tito.io"), fails: &fails})
+ if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusBadGateway || errorText(out) != "could not record the move. try again" {
+ t.Fatalf("first request = %d %s", code, out)
+ }
+ a, err := hostops.ReadAdmission(ctx, fx.rel, "bab", "titogo", claimMoveAppID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var failed string
+ for id := range a.Value.Registrations {
+ failed = id
+ }
+ if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusAccepted {
+ t.Fatalf("retry after the failure cleared = %d %s (failed attempt's effect: %+v)", code, out, a.Value.Effects[failed])
+ }
+ if err := w.control.SettleRegistration(ctx, "bab", "titogo", claimMoveAppID, failed, time.Now().Add(hostops.RegistrationTTL+2*hostops.RegistrationSkew)); err != nil {
+ t.Fatalf("the failed attempt's registration does not drain: %v", err)
+ }
+ if a, err = hostops.ReadAdmission(ctx, fx.rel, "bab", "titogo", claimMoveAppID); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := a.Value.Registrations[failed]; ok {
+ t.Fatal("the failed attempt's registration is still held")
+ }
+}