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>
6 files changed,
+162
−33
approved_moves_copy_test.go+1 −1docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+2 −1hostclaim_cli.go+20 −8hostclaim_cli_test.go+85 −0move.go+27 −23move_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"} |