carlos / platform Public

move abort door: M-C13 on a host-claim console once the cut has started (final review I5)

Binding ruling L5 maps a post-cut abort to M-C13 on both consoles. The
claim-on branch went straight to claimConsoleMove, whose typed grant
refuses any abort after the seal with ErrWrongState, so the operator was
told "the move changed". The door now answers M-C13 after the operator
gate and before the grant, from the record the gate read, when the move
is past cutting/requested (moveCutStarted). A stuck move still gets the
grant's own answer. A console without its claim runtime keeps HC5-N1, and
a record with no move keeps its refusal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev eb840935050f7f6aaf464ea4c389842c859a1ccd parent c2763b1
2 files changed, +73 −3
  • internal/console/apiinstancemove.go +12 −3
  • internal/console/apiinstancemove_j17_test.go +61 −0
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go
index 8a9e4e48b..9fd2ffc44 100644
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
@@ -919,9 +919,12 @@ func (s *Server) handleAPIInstanceMoveCut(w http.ResponseWriter, r *http.Request
// claim-side effect: the claim stays moving until the source's interim
// abort deletes staging and releases it (hostclaim.ReleaseMove, Task 14).
// A Legacy() record takes the legacy MoveAbort (F7(a)), the one transition
-// that accepts it. On a console without the host claim, once the cut has
-// started the answer is M-C13 (abortBeforeTheCut, preflight L5); a host-claim
-// console aborts through claim Plan 10's typed grant (claimConsoleMove).
+// that accepts it. Once the cut has started the answer is M-C13 (preflight
+// L5) on either console: without the host claim abortBeforeTheCut decides
+// it under the record's CAS; a host-claim console decides it from the record
+// the operator gate read, before claim Plan 10's typed grant
+// (claimConsoleMove), which after the seal would refuse with "the move
+// changed" (final review I5). A stuck move keeps the grant's own answer.
func (s *Server) handleAPIInstanceMoveAbort(w http.ResponseWriter, r *http.Request) {
if !s.cfg.MovesEnabled {
s.legacyHandleAPIInstanceMoveAbort(w, r)
@@ -936,6 +939,12 @@ func (s *Server) handleAPIInstanceMoveAbort(w http.ResponseWriter, r *http.Reque
return
}
if s.cfg.HostClaim {
+ // A console without its claim runtime keeps HC5-N1 for every move
+ // (claimConsoleMove), and a record with no move keeps its refusal.
+ if a := s.cfg.ClaimAuthority; a != nil && a.Control != nil && rec.Move != nil && moveCutStarted(rec.Move) {
+ jsonError(w, http.StatusConflict, moveCutStartedSentence)
+ return
+ }
s.claimConsoleMove(w, r, acct, app, host, rec, hostops.ConsoleMoveAbort)
return
}
diff --git a/internal/console/apiinstancemove_j17_test.go b/internal/console/apiinstancemove_j17_test.go
index 0e459dcec..d5411b5b1 100644
--- a/internal/console/apiinstancemove_j17_test.go
+++ b/internal/console/apiinstancemove_j17_test.go
@@ -170,3 +170,64 @@ func TestJ17DoorRefusesWithoutRegistration(t *testing.T) {
// through SetRecordMove (covered by the Phase 1 door tests), and a host-claim
// console without its runtime still refuses with HC5-N1
// (TestMoveDoorPhase1RefusedUnderHostClaim, TestResolveRetryRefusedUnderHostClaim).
+
+// Final review I5 (preflight L5, claim-on half): on a host-claim console,
+// once the cut has started (cutting past requested, landing, committed,
+// rolling back, reverting) the abort door answers M-C13 before the typed
+// grant, which after the seal would refuse with "the move changed". Nothing
+// is written. A stuck move keeps the grant's own answer, and an abort from
+// cutting/requested before the seal is still the typed abort.
+func TestJ17DoorAbortAfterTheCutStartedSaysSo(t *testing.T) {
+ ctx := context.Background()
+ fx, token, moveURL, _ := j17StartedMove(t, "code-j17-mc13")
+ setPhase := func(phase string) {
+ t.Helper()
+ obs, err := hostclaim.Read(ctx, fx.rel, "acme.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := hostclaim.Update(ctx, fx.rel, obs, func(c *hostclaim.Claim) error {
+ c.Op.Phase = phase
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ }
+ setPhase(hostclaim.PhaseSealed)
+ for _, c := range []struct {
+ name string
+ mut func(*provision.MoveState)
+ }{
+ {"cutting/admitted", func(m *provision.MoveState) { m.State, m.CutPhase = provision.MoveCutting, provision.CutAdmitted }},
+ {"cutting/staged", func(m *provision.MoveState) { m.State, m.CutPhase = provision.MoveCutting, provision.CutStaged }},
+ {"landing", func(m *provision.MoveState) { m.State = provision.MoveLanding }},
+ {"committed", func(m *provision.MoveState) { m.State = provision.MoveCommitted }},
+ {"rolling back", func(m *provision.MoveState) { m.State = provision.MoveRollingBack }},
+ {"reverting", func(m *provision.MoveState) { m.State = provision.MoveReverting }},
+ } {
+ j17SetRecord(t, fx, func(r *provision.Record) { c.mut(r.Move) })
+ claim, before := claimBytes(t, fx, "acme.go.tito.io"), j17MoveState(t, fx)
+ if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict || errorText(out) != moveCutStartedSentence {
+ t.Fatalf("abort in %s = %d %s, want M-C13", c.name, code, out)
+ }
+ if after := j17MoveState(t, fx); after.State != before.State || !after.UpdatedAt.Equal(before.UpdatedAt) || claimBytes(t, fx, "acme.go.tito.io") != claim {
+ t.Fatalf("a refused abort in %s wrote", c.name)
+ }
+ }
+ j17SetRecord(t, fx, func(r *provision.Record) {
+ r.Move.State, r.Move.CutPhase, r.Move.StuckReason = provision.MoveStuck, provision.CutArchived, provision.StuckBaselineMismatch
+ })
+ if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict || errorText(out) == moveCutStartedSentence {
+ t.Fatalf("abort of a stuck move = %d %s, want the grant's own refusal", code, out)
+ }
+ setPhase(hostclaim.PhaseMoving)
+ j17SetRecord(t, fx, func(r *provision.Record) {
+ r.Move.State, r.Move.CutPhase, r.Move.StuckReason = provision.MoveCutting, provision.CutRequested, ""
+ })
+ if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusAccepted {
+ t.Fatalf("abort from cutting/requested = %d %s", code, out)
+ }
+ if m := j17MoveState(t, fx); m.State != provision.MoveAborting {
+ t.Fatalf("record move = %+v", m)
+ }
+}