carlos / platform Public

console: a failed host-claim record start promises no retry (MV-N179) until the claim code can resolve its effect (E-switch fix round 2)

On a host-claim console the record-start effect of a failed start stays
uncertain and refuses the next start on the record, so "could not record
the move. try again" was untrue there. That path now answers the new
pending MV-N179, "the move could not be recorded and is held until an
operator checks the record". settleFailedMoveWrite takes held to pick the
sentence; the plain path keeps its retry sentence, byte for byte.

The KNOWN GAP skip on TestMoveDoorHostClaimRetryAfterAFailedRecordStart
stays; its note now says the resolver is requested from the claim session.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 2c8f0ddfdeefce96154800e1d5a5d112a3bcb674 parent 26d9e4d
4 files changed, +30 −11
  • approved_moves_copy_test.go +1 −1
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +2 −1
  • internal/console/apiinstancemove.go +14 −4
  • internal/console/apiinstancemove_claim_test.go +13 −5
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 6756eef9e..bdb100929 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-N178",
+ "MV-N175", "MV-N176", "MV-N177", "MV-N178", "MV-N179",
}
// 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 f35c797c2..18ac421a3 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-N178; the rest appended by Plan 2 Task 11)
+(Task 10 rows, then task E-switch's MV-N173 to MV-N179; the rest appended by Plan 2 Task 11)
| id | file | text | status |
|---|---|---|---|
@@ -225,6 +225,7 @@ Columns: id, file, text, status.
| 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 |
+| MV-N179 | internal/console/apiinstancemove.go | the move could not be recorded and is held until an operator checks the record | pending |
## Plan 3
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go
index b1c5b21c9..dd020172d 100644
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
@@ -625,7 +625,7 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req
got, err := provision.SetRecordMove(ctx, s.cfg.Releases, acct.Sqid, app, host, start)
if err != nil {
log.Printf("console: api instance move %s/%s/%s: the claim is moving under %s but the record write failed: %v", acct.Sqid, app, host, id, err)
- got = s.settleFailedMoveWrite(w, r, ctx, acct, app, host, id, exec, moving)
+ got = s.settleFailedMoveWrite(w, r, ctx, acct, app, host, id, exec, moving, false)
if got == nil {
return
}
@@ -725,7 +725,10 @@ func (s *Server) startClaimModeMove(w http.ResponseWriter, r *http.Request, acct
got, err := hostops.StartClaimMoveRecord(ctx, s.cfg.Releases, h, moving, ms)
if err != nil {
log.Printf("console: api instance move %s/%s/%s: the claim is moving under %s but the record start failed: %v", acct.Sqid, app, host, ms.ID, err)
- got = s.settleFailedMoveWrite(w, r, h.Lifecycle(ctx), acct, app, host, ms.ID, obs.Claim.Op.Exec, moving)
+ // No retry is promised (MV-N179): the record-start effect is left
+ // uncertain and refuses the next start on this record until the
+ // claim code resolves it (task E-switch fix round 2).
+ got = s.settleFailedMoveWrite(w, r, h.Lifecycle(ctx), acct, app, host, ms.ID, obs.Claim.Op.Exec, moving, true)
if got == nil {
return
}
@@ -752,10 +755,13 @@ func (s *Server) startClaimModeMove(w http.ResponseWriter, r *http.Request, acct
// claimCtx carries the authority the release's claim writes run under: the
// request's own context on the plain path, the move registration's
// lifecycle grant on a host-claim console (whose data boundary refuses an
-// unbound claim CAS).
+// unbound claim CAS). held picks the 502's sentence once the claim is
+// released: the plain path's invites a retry; the host-claim path's
+// (MV-N179, held) does not, because its record-start effect stays uncertain
+// and blocks the next one until the claim code can resolve it.
//
// It answers w itself and returns nil in every case but the first.
-func (s *Server) settleFailedMoveWrite(w http.ResponseWriter, r *http.Request, claimCtx context.Context, acct *Account, app, host, id, exec string, moving hostclaim.Observed) *provision.Record {
+func (s *Server) settleFailedMoveWrite(w http.ResponseWriter, r *http.Request, claimCtx context.Context, acct *Account, app, host, id, exec string, moving hostclaim.Observed, held bool) *provision.Record {
ctx := r.Context()
unknown := func(why string, err error) {
log.Printf("console: api instance move %s/%s/%s: move %s: %s: %v; the claim is left moving", acct.Sqid, app, host, id, why, err)
@@ -788,6 +794,10 @@ func (s *Server) settleFailedMoveWrite(w http.ResponseWriter, r *http.Request, c
unknown("claim release", err)
return nil
}
+ if held {
+ jsonError(w, http.StatusBadGateway, "the move could not be recorded and is held until an operator checks the record")
+ return nil
+ }
jsonError(w, http.StatusBadGateway, "could not record the move. try again")
return nil
}
diff --git a/internal/console/apiinstancemove_claim_test.go b/internal/console/apiinstancemove_claim_test.go
index 30b76319a..2abc27a58 100644
--- a/internal/console/apiinstancemove_claim_test.go
+++ b/internal/console/apiinstancemove_claim_test.go
@@ -278,6 +278,11 @@ func TestMoveDoorStartIsWhatC7Converts(t *testing.T) {
}
}
+// claimMoveHeldSentence is MV-N179: on a host-claim console a failed record
+// start leaves its admission effect uncertain (the KNOWN GAP below), so the
+// 502 promises no retry.
+const claimMoveHeldSentence = "the move could not be recorded and is held until an operator checks the record"
+
// 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 {
@@ -419,7 +424,7 @@ func TestMoveDoorHostClaimFailedRecordStartReleasesTheClaim(t *testing.T) {
fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-release")
armClaimMoves(t, fx, true, true, failRecordPut{Store: fx.rel, key: provision.RecordKey("bab", "titogo", "acme.go.tito.io")})
code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`)
- if code != http.StatusBadGateway || errorText(out) != "could not record the move. try again" || strings.Contains(out, "secret-detail") {
+ if code != http.StatusBadGateway || errorText(out) != claimMoveHeldSentence || strings.Contains(out, "secret-detail") {
t.Fatalf("request = %d %s", code, out)
}
obs, err := hostclaim.Read(ctx, fx.rel, "acme.go.tito.io")
@@ -458,15 +463,18 @@ func TestMoveDoorHostClaimFailedRecordStartReleasesTheClaim(t *testing.T) {
// 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.
+// API that resolves an uncertain record effect; the claim code owns that,
+// and the resolver has been requested from the claim session (fix round 2).
+// Until it lands the door answers MV-N179, which promises no retry. When it
+// lands: the compensation calls it, the door answers a retryable sentence
+// again on a "not-applied" outcome, and this skip goes.
func TestMoveDoorHostClaimRetryAfterAFailedRecordStart(t *testing.T) {
- t.Skip("KNOWN GAP: no hostops API resolves an uncertain move-start effect (task E-switch fix round 1)")
+ t.Skip("KNOWN GAP: no hostops API resolves an uncertain move-start effect; the resolver is requested from the claim session (task E-switch fix rounds 1-2)")
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" {
+ if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusBadGateway || errorText(out) != claimMoveHeldSentence {
t.Fatalf("first request = %d %s", code, out)
}
a, err := hostops.ReadAdmission(ctx, fx.rel, "bab", "titogo", claimMoveAppID)