console: the delete door answers M-C19 while the host is being moved (Plan 6 Task 2b, L6)
With CARLOS_MOVES_PHASE1 on, the delete door checks before any retire effect (refuseDeleteDuringMove in apiinstancemove.go): - a console without the host claim refuses a record that carries a move, in any state; - a host-claim console refuses a claim whose operation is a move's (moving, landing, reverting, restoring; relocating only while the record still journals the move), read through hostclaim.Read only. The answer is 409 M-C19 (moveDeleteRefusal); an unreadable record or claim is 502 HC5-N3. With the switch off the door is main's. M-C19's File cell moves to apiinstancemove.go and the row is approved verbatim.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+175
−2
approved_moves_copy_test.go+2 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+2 −2internal/console/apiinstancemove.go+56 −0internal/console/apiinstancemove_delete_test.go+110 −0internal/console/apiinstances.go+5 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 4a80110fa..771474c20 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -747,6 +747,8 @@ var movesReturnedSentences = map[string]map[string]bool{ |
| "internal/host/movesreport.go": {"t1Below": true, "t3Differs": true, "t3Shadowed": true, "t4Failed": true, |
| "t6NoRoom": true, "t8Count": true, "t12NotQuiet": true, "replicaReason": true}, |
| "internal/host/movesrollback.go": {"stuckPage": true}, |
| + // moveDeleteRefusal is M-C19, which the delete door answers (Task 2b). |
| + "internal/console/apiinstancemove.go": {"moveDeleteRefusal": true}, |
| } |
| // movesLiteralFuncs are functions whose every string literal is a sentence |
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 898939b30..39ea2cff7 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -31,7 +31,7 @@ Columns: id, file, text, status. |
| | M-C16 | internal/host/movesphase1.go | <host> does not answer its readiness path. Declare moves.ready_path for this app. | approved | |
| | M-C17 | internal/console/apiinstancemove.go | <host> uses private storage. Instances with private storage cannot move yet. | approved | |
| | M-C18 | move.go | This declares <box> lost. Anything <host> wrote on <box> after <time> is lost. Type the host name to confirm. | pending | |
| -| M-C19 | internal/console/apiinstances.go | <host> is being moved. Delete it after the move finishes, or abort the move first. | pending | |
| +| M-C19 | internal/console/apiinstancemove.go | <host> is being moved. Delete it after the move finishes, or abort the move first. | approved | |
| | M-C20 | internal/host/movespaging.go | <box> is running a process for <host> that this move cannot account for. An operator has been paged. | pending | |
| | M-C21 | internal/console/apiinstancemove.go | <region> has more than one box. Moves between regions with several boxes are not supported yet. | approved | |
| | M-C22 | move.go | <host> is waiting for a box in <region>. Its last box was terminated before the move finished. Its data up to the last completed upload is kept in <region>. It will start when a box joins the region, and any writes after that upload will be reported then. | pending | |
| @@ -284,7 +284,7 @@ Columns: id, file, text, status. |
| ## Plan 6 |
| -(completed by Plan 6 Task 7. Task 2 takes MV-N319 to MV-N324, and made the abort door speak M-C13 on a console without the host claim, so M-C13 is now approved verbatim) |
| +(completed by Plan 6 Task 7. Task 2 takes MV-N319 to MV-N324, and made the abort door speak M-C13 on a console without the host claim, so M-C13 is now approved verbatim. Task 2b made the delete door speak M-C19 through moveDeleteRefusal in internal/console/apiinstancemove.go, so its File cell moves there (preflight L6) and it is approved verbatim) |
| | id | file | text | status | |
| |---|---|---|---| |
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index e918003ad..e7dd25147 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -1473,3 +1473,59 @@ func (s *Server) regionHasLiveBox(ctx context.Context, region string) (live, kno |
| } |
| return len(eligibleBoxes(snap, roster, region, aws, s.cfg.HomeRegion, s.expectedSecretsFP(), nil)) > 0, true |
| } |
| + |
| +// ---- the delete door during a move (Plan 6 Task 2b, preflight L6) ---- |
| + |
| +// moveDeleteRefusal is M-C19, what the delete door says for a host that is |
| +// being moved. |
| +func moveDeleteRefusal(host string) string { |
| + return host + " is being moved. Delete it after the move finishes, or abort the move first." |
| +} |
| + |
| +// moveClaimKinds are the claim operations a move runs under. A relocating |
| +// operation is a move's only when the record still journals the move (a |
| +// region HA relocation of its own is not one). |
| +var moveClaimKinds = map[string]bool{hostclaim.KindMoving: true, hostclaim.KindLanding: true, hostclaim.KindReverting: true, hostclaim.KindRestoring: true} |
| + |
| +// refuseDeleteDuringMove is the delete door's move check, with the switch |
| +// on: before any retire effect, a host that is being moved answers 409 |
| +// M-C19 and nothing is written. On a console without the host claim that is |
| +// a record carrying a move (any state, legacy included); on a host-claim |
| +// console it is a claim whose operation is a move's. The claim would refuse |
| +// the retire anyway; this check only says why, before the retire registers |
| +// anything. A record that is not there is main's idempotent delete. A read |
| +// that fails is a 502 (HC5-N3), never a pass. It reports whether it |
| +// answered. |
| +func (s *Server) refuseDeleteDuringMove(ctx context.Context, w http.ResponseWriter, acct, app, host string) bool { |
| + if !s.cfg.MovesEnabled { |
| + return false |
| + } |
| + rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct, app, host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return false |
| + } |
| + if err != nil { |
| + log.Printf("console: api instances delete %s/%s/%s: move check: record: %v", acct, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the instance") |
| + return true |
| + } |
| + moving := rec.Move != nil |
| + if s.cfg.HostClaim { |
| + obs, err := hostclaim.Read(ctx, s.cfg.Releases, host) |
| + if err != nil { |
| + log.Printf("console: api instances delete %s/%s/%s: move check: claim: %v", acct, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the instance") |
| + return true |
| + } |
| + kind := "" |
| + if !obs.Absent && obs.Claim != nil { |
| + kind = obs.Claim.Op.Kind |
| + } |
| + moving = moveClaimKinds[kind] || (kind == hostclaim.KindRelocating && rec.Move != nil) |
| + } |
| + if !moving { |
| + return false |
| + } |
| + jsonError(w, http.StatusConflict, moveDeleteRefusal(host)) |
| + return true |
| +} |
diff --git a/internal/console/apiinstancemove_delete_test.go b/internal/console/apiinstancemove_delete_test.go| new file mode 100644 |
| index 000000000..d6913715a |
| --- /dev/null |
| +++ b/internal/console/apiinstancemove_delete_test.go |
| @@ -0,0 +1,110 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http" |
| + "os" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// mc19 is M-C19 for the fixture's host. |
| +const mc19 = "acme.go.tito.io is being moved. Delete it after the move finishes, or abort the move first." |
| + |
| +func recordExists(t *testing.T, fx *consoleFixture) bool { |
| + t.Helper() |
| + _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io") |
| + if errors.Is(err, os.ErrNotExist) { |
| + return false |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return true |
| +} |
| + |
| +// Task 2b (preflight L6): with the switch on, a console without the host |
| +// claim refuses to delete an instance whose record carries a live move, |
| +// with M-C19, and deletes nothing. Once the move has closed the delete is |
| +// main's. |
| +func TestMoveDeleteRefusesDuringAMove(t *testing.T) { |
| + fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-del") |
| + instURL := strings.TrimSuffix(moveURL, "/move") |
| + readMoveAnswer(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`, http.StatusAccepted) |
| + for _, state := range []string{provision.MovePreparing, provision.MoveCommitted, provision.MoveStuck} { |
| + setMove(t, fx, func(rec *provision.Record) { rec.Move.State = state }) |
| + if code, _, out := rawReq(t, http.MethodDelete, instURL, token, ""); code != http.StatusConflict || errorText(out) != mc19 { |
| + t.Fatalf("delete during a move in %s = %d %s, want M-C19", state, code, out) |
| + } |
| + if !recordExists(t, fx) { |
| + t.Fatalf("a refused delete in %s removed the record", state) |
| + } |
| + } |
| + mv := recordMove(t, fx) |
| + setMove(t, fx, func(rec *provision.Record) { rec.Move.State = provision.MoveAborting }) |
| + if _, err := provision.SetRecordMove(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io", |
| + provision.MoveGuardedAbortComplete(provision.MoveGuard{ID: mv.ID, Incarnation: mv.Incarnation}, time.Now())); err != nil { |
| + t.Fatal(err) |
| + } |
| + if code, _, out := rawReq(t, http.MethodDelete, instURL, token, ""); code != http.StatusOK { |
| + t.Fatalf("delete after the move closed = %d %s", code, out) |
| + } |
| + if recordExists(t, fx) { |
| + t.Fatal("the delete after the move closed left the record") |
| + } |
| +} |
| + |
| +// With the switch off the delete door is main's: a record with a move is |
| +// deleted as before. |
| +func TestMoveDeleteSwitchOffIsMains(t *testing.T) { |
| + fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-del-off") |
| + instURL := strings.TrimSuffix(moveURL, "/move") |
| + setMove(t, fx, func(rec *provision.Record) { |
| + rec.Move = &provision.MoveState{To: "uk", State: provision.MovePreparing, RequestedAt: time.Now().UTC()} |
| + }) |
| + fx.srv.cfg.MovesEnabled = false |
| + if code, _, out := rawReq(t, http.MethodDelete, instURL, token, ""); code != http.StatusOK { |
| + t.Fatalf("switch-off delete during a legacy move = %d %s", code, out) |
| + } |
| + if recordExists(t, fx) { |
| + t.Fatal("the switch-off delete left the record") |
| + } |
| +} |
| + |
| +// On a host-claim console a delete during a move meets the claim's pending |
| +// refusal; the door says M-C19 for it, not the generic busy sentence, when |
| +// the claim's operation is the move's. |
| +func TestMoveDeleteUnderHostClaimSaysItIsMoving(t *testing.T) { |
| + fx, token, moveURL, _ := j17StartedMove(t, "code-inst-move-del-claim") |
| + instURL := strings.TrimSuffix(moveURL, "/move") |
| + code, _, out := rawReq(t, http.MethodDelete, instURL, token, "") |
| + if code != http.StatusConflict || errorText(out) != mc19 { |
| + t.Fatalf("delete under the host claim during a move = %d %s, want M-C19", code, out) |
| + } |
| + if !recordExists(t, fx) { |
| + t.Fatal("a refused delete removed the record") |
| + } |
| +} |
| + |
| +// A host-claim console says M-C19 only for a move's claim operation: a |
| +// serving claim, and a relocation with no move journaled on the record (a |
| +// region HA relocation of its own), go on to the claim's retire. |
| +func TestMoveDeleteUnderHostClaimOnlyForAMove(t *testing.T) { |
| + fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-del-claim2") |
| + armClaimMoves(t, fx, true, true, nil) |
| + instURL := strings.TrimSuffix(moveURL, "/move") |
| + if code, _, out := rawReq(t, http.MethodDelete, instURL, token, ""); errorText(out) == mc19 { |
| + t.Fatalf("delete of a serving host under the host claim = %d %s", code, out) |
| + } |
| + rewriteClaim(t, fx, "acme.go.tito.io", func(c *hostclaim.Claim) { |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "op-ha", Phase: "pinning", Exec: "none", Intent: map[string]string{"region": "ie", "source_box": "ie-box"}} |
| + }) |
| + if code, _, out := rawReq(t, http.MethodDelete, instURL, token, ""); errorText(out) == mc19 { |
| + t.Fatalf("delete during a relocation that is not a move = %d %s", code, out) |
| + } |
| +} |
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index 06218c128..1de4f4879 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -1785,6 +1785,11 @@ func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request) |
| jsonError(w, http.StatusBadRequest, "invalid host") |
| return |
| } |
| + // With CARLOS_MOVES_PHASE1 on, a host being moved is not deleted |
| + // (M-C19, apiinstancemove.go); with it off this is main's door. |
| + if s.refuseDeleteDuringMove(ctx, w, acct.Sqid, app, host) { |
| + return |
| + } |
| if err := s.retireInstanceRecord(ctx, acct.Sqid, app, host, principal, ""); err != nil { |
| // An accepted delete is this principal's durable request: audit it |
| // and announce it before answering 202 with the operation. |