carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 2a96740cbdfc425b6a239954b9f2d8daf506bcab parent 4f31754
5 files changed, +175 −2
  • approved_moves_copy_test.go +2 −0
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +2 −2
  • internal/console/apiinstancemove.go +56 −0
  • internal/console/apiinstancemove_delete_test.go +110 −0
  • internal/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.