carlos / platform Public

carlos move --declare-lost: M-C18 bound to the inspected move; one response policy; the ops adapter tested (Task 1b fix round 1)

1. Before M-C18 the CLI requires the detail read's move to be the move
   the inspection's challenge names (Details.Op.ID), and its incarnation
   when both carry one. Otherwise it says MV-N379 (pending), declares
   nothing and tries nothing again.
2. accepted calls refusal for its error branches: one implementation of
   the output and exit-code policy.
3. resolveHost reads stdin, writes stdout and opens the EC2 reader through
   hostResolveIO (os.Stdin, os.Stdout, awsinv.NewEC2Reader by default);
   TestOpsHostResolveDeclareLostAdapter holds its URLs, bodies, prompt and
   final answer byte for byte against an httptest console.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 34f9edfe919cf3393f6400e11a795327dfcd8094 parent 5c8fb2a
6 files changed, +162 −33
  • approved_moves_copy_test.go +1 −1
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +2 −1
  • hostclaim_cli.go +20 −8
  • hostclaim_cli_test.go +85 −0
  • move.go +27 −23
  • move_test.go +27 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index e69fcbcb9..960f9a71b 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -79,7 +79,7 @@ var movesCopyIDs = []string{
"MV-N350", "MV-N351", "MV-N352", "MV-N353", "MV-N354", "MV-N355", "MV-N356",
"MV-N357", "MV-N358", "MV-N359", "MV-N360", "MV-N361", "MV-N362", "MV-N363", "MV-N364",
"MV-N365", "MV-N366", "MV-N367", "MV-N368", "MV-N369", "MV-N370", "MV-N371",
- "MV-N372", "MV-N373", "MV-N374", "MV-N375", "MV-N376", "MV-N377", "MV-N378",
+ "MV-N372", "MV-N373", "MV-N374", "MV-N375", "MV-N376", "MV-N377", "MV-N378", "MV-N379",
}
// 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 56f1eb305..0f32c72ba 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -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. 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. Task 1a takes MV-N350 to MV-N371 and Task 1b MV-N372 to MV-N378 (Plan 5 owns MV-N325 to MV-N349) for `carlos move` (move.go, and its usage line in main.go), and speaks M-C1 to M-C12 and M-C22 verbatim from move.go, so those rows are approved. Task 1b speaks M-C18 verbatim from move.go and MV-N11 from the resolve door of a console without the host claim, so both are approved)
+(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. Task 1a takes MV-N350 to MV-N371 and Task 1b MV-N372 to MV-N379 (Plan 5 owns MV-N325 to MV-N349) for `carlos move` (move.go, and its usage line in main.go), and speaks M-C1 to M-C12 and M-C22 verbatim from move.go, so those rows are approved. Task 1b speaks M-C18 verbatim from move.go and MV-N11 from the resolve door of a console without the host claim, so both are approved)
| id | file | text | status |
|---|---|---|---|
@@ -318,6 +318,7 @@ Columns: id, file, text, status.
| MV-N376 | move.go | The move's flip time could not be read, so its target cannot be declared lost from here. | pending |
| MV-N377 | move.go | The console asks to confirm a loss carlos move does not know. Use carlos ops host resolve. | pending |
| MV-N378 | move.go | carlos move resolve takes --retry or --declare-lost, not both. | pending |
+| MV-N379 | move.go | The move changed since it was read. Nothing was declared. Run the command again. | pending |
## Review of 2026-10-09
diff --git a/hostclaim_cli.go b/hostclaim_cli.go
index be78374e1..07f9fcb9b 100644
--- a/hostclaim_cli.go
+++ b/hostclaim_cli.go
@@ -9,6 +9,7 @@ import (
"github.com/carlosframework/platform/internal/awsinv"
"github.com/carlosframework/platform/internal/hostfence"
"github.com/carlosframework/platform/internal/hostresolve"
+ "io"
"log"
"net/url"
"os"
@@ -112,6 +113,17 @@ func (s *apiSession) inspectHostResolve(ctx context.Context, o hostResolveOption
return nil
}
+// hostResolveSeams are where resolveHost reads the operator's confirmation,
+// writes what it shows, and reads the box from AWS: os.Stdin, os.Stdout and
+// awsinv.NewEC2Reader, swapped only by tests.
+type hostResolveSeams struct {
+ stdin io.Reader
+ stdout io.Writer
+ reader func(ctx context.Context, region string) (awsinv.ExactEC2API, error)
+}
+
+var hostResolveIO = hostResolveSeams{stdin: os.Stdin, stdout: os.Stdout, reader: awsinv.NewEC2Reader}
+
func (s *apiSession) resolveHost(ctx context.Context, o hostResolveOptions) error {
base := "/api/cli/ops/hosts/" + url.PathEscape(o.Host) + "/resolve"
status, body, err := s.postJSON(ctx, base+"/inspect", map[string]any{"action": o.Action, "box": o.Box})
@@ -126,10 +138,10 @@ func (s *apiSession) resolveHost(ctx context.Context, o hostResolveOptions) erro
if err = json.Unmarshal(body, &request); err != nil {
return err
}
- fmt.Fprintln(os.Stdout, string(body))
+ fmt.Fprintln(hostResolveIO.stdout, string(body))
if !o.Yes {
- fmt.Fprint(os.Stdout, "Readmit this exact enrollment and revision? Type readmit: ")
- line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
+ fmt.Fprint(hostResolveIO.stdout, "Readmit this exact enrollment and revision? Type readmit: ")
+ line, _ := bufio.NewReader(hostResolveIO.stdin).ReadString('\n')
if strings.TrimSpace(line) != "readmit" {
return errors.New("readmission not confirmed")
}
@@ -149,16 +161,16 @@ func (s *apiSession) resolveHost(ctx context.Context, o hostResolveOptions) erro
// TLS-authenticated console response; the console verifies its
// unchanged signed envelope on submission. The client does not
// call this AWS proof.
- fmt.Fprintln(os.Stdout, string(body))
+ fmt.Fprintln(hostResolveIO.stdout, string(body))
return inspection, nil
},
confirm: func(inspection hostresolve.Inspection) bool {
- fmt.Fprintln(os.Stdout, "Confirm that compute remains fenced, any restart will be cold through quarantine, and the listed storage was destroyed if asserted. Type the declaration ID:")
- line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
+ fmt.Fprintln(hostResolveIO.stdout, "Confirm that compute remains fenced, any restart will be cold through quarantine, and the listed storage was destroyed if asserted. Type the declaration ID:")
+ line, _ := bufio.NewReader(hostResolveIO.stdin).ReadString('\n')
// --yes does not silently attest facts about compute or disks.
return strings.TrimSpace(line) == inspection.Details.ID
},
- reader: awsinv.NewEC2Reader,
+ reader: hostResolveIO.reader,
declare: func(ctx context.Context, submission hostfence.Submission) error {
status, body, err = s.postJSON(ctx, base, map[string]any{"action": o.Action, "submission": submission})
return err
@@ -175,7 +187,7 @@ func (s *apiSession) resolveHost(ctx context.Context, o hostResolveOptions) erro
if status != 200 {
return apiErrorFromBody(status, body)
}
- fmt.Fprintln(os.Stdout, string(body))
+ fmt.Fprintln(hostResolveIO.stdout, string(body))
return nil
}
diff --git a/hostclaim_cli_test.go b/hostclaim_cli_test.go
index e22ebcf4e..6d72672b5 100644
--- a/hostclaim_cli_test.go
+++ b/hostclaim_cli_test.go
@@ -1,9 +1,14 @@
package main
import (
+ "bytes"
"context"
"encoding/json"
"errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
"testing"
"time"
@@ -100,3 +105,83 @@ func TestDeclareHostLossStages(t *testing.T) {
t.Fatalf("running: stage %d, %v", stage, err)
}
}
+
+// Task 1b fix round 1, finding 3: the ops door's adapter around the shared
+// declare half, against an httptest console. Its URLs, bodies, prompt and the
+// final answer are what carlos ops host resolve has always sent and printed,
+// byte for byte.
+func TestOpsHostResolveDeclareLostAdapter(t *testing.T) {
+ in := hostresolve.Inspection{Challenge: json.RawMessage(`{"signed":"by the test"}`)}
+ c, err := hostfence.PrepareChallenge(time.Now().Add(-time.Second), hostfence.Challenge{
+ ID: "loss-ops-1", Actor: "op@keymail.dev", Host: "app.example.com", HolderIncarnation: "inc-1",
+ ClaimGen: 9, ClaimETag: "etag", Op: hostfence.Operation{ID: "op-1", Kind: "landing"},
+ Record: hostfence.RecordObservation{Key: "k", ETag: "etag-record", CommitState: "present"}, Box: "box-1", Enrollment: "e",
+ Cloud: hostfence.CloudBinding{Provider: "aws", Account: "111122223333", Region: "eu-west-1", InstanceID: "i-1"},
+ Consequences: hostfence.Consequences{Transition: "move-target-lost"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ in.Details = c
+ inspectBody, _ := json.Marshal(in)
+ finalBody := []byte(`{"result":"declared","host":"app.example.com"}`)
+ var calls []string
+ var declared map[string]json.RawMessage
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ calls = append(calls, r.Method+" "+r.URL.Path+" "+string(body))
+ switch r.URL.Path {
+ case "/api/cli/ops/hosts/app.example.com/resolve/inspect":
+ _, _ = w.Write(inspectBody)
+ case "/api/cli/ops/hosts/app.example.com/resolve":
+ _ = json.Unmarshal(body, &declared)
+ _, _ = w.Write(finalBody)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer ts.Close()
+ var out bytes.Buffer
+ ec2Calls := 0
+ saved := hostResolveIO
+ hostResolveIO = hostResolveSeams{
+ stdin: strings.NewReader("loss-ops-1\n"),
+ stdout: &out,
+ reader: func(_ context.Context, region string) (awsinv.ExactEC2API, error) {
+ if region != "eu-west-1" {
+ t.Errorf("reader asked for %q", region)
+ }
+ return fakeEC2{account: "111122223333", instance: "i-1", state: "stopped", calls: &ec2Calls}, nil
+ },
+ }
+ defer func() { hostResolveIO = saved }()
+ sess := &apiSession{c: newConsoleClient(ts.URL, "cli-token"), console: ts.URL, address: "op@keymail.dev"}
+ if err := sess.resolveHost(context.Background(), hostResolveOptions{Host: "app.example.com", Action: "declare-lost", Box: "box-1"}); err != nil {
+ t.Fatal(err)
+ }
+ want := string(inspectBody) + "\n" +
+ "Confirm that compute remains fenced, any restart will be cold through quarantine, and the listed storage was destroyed if asserted. Type the declaration ID:\n" +
+ string(finalBody) + "\n"
+ if out.String() != want {
+ t.Fatalf("output:\n%q\nwant\n%q", out.String(), want)
+ }
+ if len(calls) != 2 || calls[0] != `POST /api/cli/ops/hosts/app.example.com/resolve/inspect {"action":"declare-lost","box":"box-1"}` ||
+ !strings.HasPrefix(calls[1], `POST /api/cli/ops/hosts/app.example.com/resolve {"action":"declare-lost","submission":`) {
+ t.Fatalf("calls %q", calls)
+ }
+ var sub hostfence.Submission
+ if err := json.Unmarshal(declared["submission"], &sub); err != nil || string(sub.Challenge) != `{"signed":"by the test"}` ||
+ sub.Confirmation != "loss-ops-1" || sub.Observation.State != "stopped" || !sub.Attestation.MaintainComputeFence || ec2Calls != 1 {
+ t.Fatalf("submission %+v (%v), %d EC2 calls", sub, err, ec2Calls)
+ }
+
+ // A wrong declaration ID refuses with the error it always has, and
+ // declares nothing.
+ calls, out = nil, bytes.Buffer{}
+ hostResolveIO.stdin = strings.NewReader("not-the-id\n")
+ hostResolveIO.stdout = &out
+ err = sess.resolveHost(context.Background(), hostResolveOptions{Host: "app.example.com", Action: "declare-lost", Box: "box-1"})
+ if err == nil || err.Error() != "fencing attestation not confirmed" || len(calls) != 1 {
+ t.Fatalf("wrong ID: %v, calls %q", err, calls)
+ }
+}
diff --git a/move.go b/move.go
index ee47d411a..1edb02327 100644
--- a/move.go
+++ b/move.go
@@ -59,7 +59,7 @@ const (
moveProtocolMark = "phase1"
)
-// The sentences this file adds (copy record MV-N350 to MV-N378, pending).
+// The sentences this file adds (copy record MV-N350 to MV-N379, pending).
const (
moveNotEnabled = "Moves are not enabled on this console."
moveNoApp = "carlos move needs --app: name the app the instance belongs to."
@@ -79,6 +79,7 @@ const (
moveNoAWS = "declare-lost needs your AWS credentials to confirm the box is stopped or terminated."
moveNoFlipTime = "The move's flip time could not be read, so its target cannot be declared lost from here."
moveOtherLoss = "The console asks to confirm a loss carlos move does not know. Use carlos ops host resolve."
+ moveLossChanged = "The move changed since it was read. Nothing was declared. Run the command again."
)
func moveSay(out io.Writer, line string) {
@@ -303,16 +304,17 @@ type moveDetailView struct {
}
type moveJournal struct {
- ID string `json:"id"`
- To string `json:"to"`
- Cut string `json:"cut"`
- State string `json:"state"`
- CutPhase string `json:"cut_phase"`
- CutBudgetS int `json:"cut_budget_s"`
- FlippedAt *time.Time `json:"flipped_at"`
- Reason string `json:"reason"`
- WireStuck string `json:"stuck_reason"` // read only by roundTrip
- Rollback *moveRollback `json:"rollback"`
+ Incarnation string `json:"incarnation"`
+ ID string `json:"id"`
+ To string `json:"to"`
+ Cut string `json:"cut"`
+ State string `json:"state"`
+ CutPhase string `json:"cut_phase"`
+ CutBudgetS int `json:"cut_budget_s"`
+ FlippedAt *time.Time `json:"flipped_at"`
+ Reason string `json:"reason"`
+ WireStuck string `json:"stuck_reason"` // read only by roundTrip
+ Rollback *moveRollback `json:"rollback"`
}
type moveRollback struct {
@@ -554,16 +556,8 @@ func (m *mover) mutate(ctx context.Context, method, tail string, body any, wait,
// accepted ends a mutation from the console's reply: a refusal or a failure
// is said and ends it; an acceptance is said (--no-wait) or waited on.
func (m *mover) accepted(ctx context.Context, r moveReply, wait, request bool) int {
- switch {
- case r.failed || r.moved != "":
- m.sayReply(r)
- return moveExitError
- case r.status >= 400 && r.status < 500:
- m.sayReply(r)
- return moveExitRefused
- case r.status < 200 || r.status > 299:
- m.sayReply(r)
- return moveExitError
+ if code := m.refusal(r); code >= 0 {
+ return code
}
view := r.flat()
if view.ID == "" || view.Protocol != moveProtocolMark {
@@ -845,9 +839,10 @@ func (m *mover) declareLost(ctx context.Context, box string, d moveDeps, wait bo
if code >= 0 {
return code
}
+ read := a.Detail.Move // the move M-C18 describes
var flipped *time.Time
- if a.Detail.Move != nil {
- flipped = a.Detail.Move.FlippedAt
+ if read != nil {
+ flipped = read.FlippedAt
}
refused := -1 // the exit code a refusal already said, if any
stdin := bufio.NewReader(d.stdin)
@@ -869,6 +864,15 @@ func (m *mover) declareLost(ctx context.Context, box string, d moveDeps, wait bo
confirm: func(in hostresolve.Inspection) bool {
switch in.Details.Consequences.Transition {
case "move-target-lost":
+ // M-C18 describes the move read above: the challenge must
+ // name that move, and its incarnation when both carry one
+ // (fix round 1, finding 1). Otherwise nothing is shown or
+ // declared, and nothing is tried again.
+ if read == nil || read.ID != in.Details.Op.ID ||
+ (read.Incarnation != "" && in.Details.HolderIncarnation != "" && read.Incarnation != in.Details.HolderIncarnation) {
+ moveSay(m.out, moveLossChanged)
+ return false
+ }
if flipped == nil {
moveSay(m.out, moveNoFlipTime)
return false
diff --git a/move_test.go b/move_test.go
index fdbf09daf..18848be6b 100644
--- a/move_test.go
+++ b/move_test.go
@@ -1032,6 +1032,33 @@ func TestMoveDeclareLost(t *testing.T) {
wantLines(t, out, "declare-lost needs your AWS credentials to confirm the box is stopped or terminated.")
wantNoLine(t, out, "no credential chain")
})
+ // Fix round 1, finding 1: M-C18 is bound to the inspected move. A detail
+ // that names another move, or another incarnation, than the inspection's
+ // challenge refuses before anything is shown, read or declared.
+ for _, c := range []struct {
+ name string
+ edit func(*hostresolve.Inspection)
+ }{
+ {"another move", func(in *hostresolve.Inspection) { in.Details.Op.ID = "mv_bbbbbbbbbbbbbbbbbbbbbbbbbb" }},
+ {"another incarnation", func(in *hostresolve.Inspection) { in.Details.HolderIncarnation = "inc-other" }},
+ } {
+ t.Run("the inspection names "+c.name, func(t *testing.T) {
+ in := lossChallenge(t, "move-target-lost")
+ c.edit(&in)
+ var sub hostfence.Submission
+ fc := lossConsole(t, in, &sub)
+ fc.frames[0]["detail"].(frame)["move"].(frame)["incarnation"] = "inc-1"
+ code, out := runMoveWith(t, fc, func(d *moveDeps) { d.stdin = strings.NewReader(tHost + "\n") }, args...)
+ if code != 5 {
+ t.Fatalf("exit %d, want 5:\n%s", code, out)
+ }
+ wantLines(t, out, "The move changed since it was read. Nothing was declared. Run the command again.")
+ wantNoLine(t, out, "This declares uk-box lost.")
+ if m := fc.mutations(); len(m) != 1 || sub.Confirmation != "" {
+ t.Fatalf("mutations %q: want the inspection only", m)
+ }
+ })
+ }
t.Run("the console refuses", func(t *testing.T) {
c := &fakeMoveConsole{frames: []frame{inFlight("landing", nil, nil)}, answer: func(string, string, string) (int, any) {
return http.StatusConflict, map[string]string{"error": "declare-lost is not enabled in this deployment yet"}