# Moves Phase 1, Plan 6: CLI, Status and Observability Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Give operators the move surface: `carlos move` with `status`, `cut`, `abort` and `resolve`, the detailed status door, exit codes, the move log in the deployment bucket, member-stream events, paging, the copy gate, and the `docs/site/cli.md` heading the docsite gate requires.

**Architecture:** The CLI verb lives in a new root-package file `move.go` dispatched from `main.go`, using `apiSession` helpers (`postJSON`, `getJSON`, `deleteJSON`) exactly as `instances_cli.go` does; `--wait` polls the status door until a terminal state and maps it to the exit code. The console's `GET …/move?detail=1` returns the record's move journal plus the reports and evidence keys; `POST …/move/resolve` carries `--retry` and `--declare-lost` (Plan 4). Every transition writes one log object under `moves/<acct>/<app>/<host>/<op>/log/` (`moves.LogEvent`) and one member-stream event (`activator.EventSink`). Paging goes through the box's existing alert sinks, exported for the moves pass and the console.

**Tech Stack:** Go, `flag`, the console's `cliRoute`, `internal/docsite`, AWS EC2 `DescribeInstances` (CLI side, for `--declare-lost`'s cloud check).

**Spec:** rev 7.18 §12, §13, §20, §7.10 (the CLI form of declare-lost), §14.4's status expectations. Index: `2026-10-06-moves-phase1-0-index.md`. Plans 1 to 5 merged (Plan 4 Task 10 may be pending: `--declare-lost` is built against its door and skips its end-to-end test until then).

## Global Constraints

- Gate, cross-compile, sandboxed `GOCACHE`; tests serial and niced.
- A new `carlos` command is not done until `docs/site/cli.md` names it under a `### carlos move` heading, every fenced example runs a real verb, and `internal/docsite`'s tests pass (AGENTS.md).
- Exit codes: 0 done, 3 rolled back, 4 stuck or waiting, 5 refused (§12). `--wait` is the default; `--no-wait` returns after the request.
- Copy: every operator-facing string of the whole series goes through the copy gate before it ships; the ids of spec §20 (M-C1 to M-C20) are applied byte for byte once approved; no em dashes; a full stop and a new sentence, lower case where the spec's draft is lower case.
- Nothing rolls to production without Paul's go; this plan's merge does not roll.

## Review Focus

1. `carlos move --wait` on a move that rolls back must print M-C8 then M-C9 (or M-C10/M-C11) and exit 3, never 0, even when the final record read shows no move (the close cleared it): the CLI tracks the last seen state. Test in Task 1 (`TestMoveWaitExitCodes`).
2. `GET …/move?detail=1` must never inline evidence bodies or secrets: keys only, and the env report's HMACs only. Test in Task 2 (`TestMoveDetailLeaksNothing`).
3. The member stream must carry one event per transition and none for a poll. Test in Task 3.
4. A paging rule that fires every pass would flood: `rolling_back` past the release deadline pages once an hour, `preparing` with no promotable round pages once, `stuck` pages once per entry into `stuck`. Test in Task 5 (`TestPagingIsRateLimited`).
5. The copy gate must fail when a string in the record is not in the file the record names, and when an id the spec lists is missing from the record. Test in Task 7 (extends `approved_copy_test.go`).

---

## File structure

| path | responsibility |
|---|---|
| `move.go`, `move_test.go` | `carlos move` and sub-verbs |
| `main.go` | dispatch `case "move"` and the usage line |
| `docs/site/cli.md` | `### carlos move` |
| `internal/console/apiinstancemove.go` | `detail=1`, `resolve` |
| `internal/moves/log.go` | `LogEvent` |
| `internal/host/movesevents.go`, `internal/host/movespaging.go` | events and paging rules from the box (the primitive is Plan 5 Task 0) |
| `approved_copy_test.go`, `docs/superpowers/plans/2026-10-06-moves-phase1-copy.md` | this plan's rows in the copy record (the gate is Plan 1 Task 15) |

---

### Task 1: `carlos move` and its CLI page heading

**Files:** create `move.go`, `move_test.go`; modify `main.go` (dispatch and usage) **and `docs/site/cli.md`** in the same task and commit: `internal/docsite`'s `TestCLICommandsAreDocumented` (`cli_test.go:58`) fails any commit that dispatches a verb the CLI page does not head (index D12). The section goes under "Your deployment" after `### carlos system`: one paragraph (operator-only; what a move does; that it rolls back on its own; the rehearsal order is Paul's), the five command lines of §12 in a `sh` fence, the exit codes, and `carlos move resolve --host <h> --declare-lost <box>` with the confirmation. `nice -n 10 go test ./internal/docsite -count=1` is part of this task's Step 4.

**Interfaces:**
- `runMove(args []string)`: `carlos move --host <h> --to <region> [--manual-cut] [--no-wait] [--app <a>] [--console <url>]`; sub-verbs `status`, `cut`, `abort`, `resolve --retry | --declare-lost <box> [--instance-id <id>] [--region <aws region>]`; `--host` resolves the account and app through `resolvedApp` when `--app` is absent (the host's record is found through `GET /api/cli/apps/{acct}/{app}/instances/{host}` under the chosen app; without `--app`, the CLI lists the account's apps and finds the one whose instance domain the host ends in, as `instances_cli.go`'s host resolution does if it exists; else `--app` is required with a sentence saying so).
- `moveWait(ctx, sess *apiSession, path, moveID string, out io.Writer, poll time.Duration) int` polls `GET <path>?detail=1` every `poll` (2 s), prints a sentence on each state change (M-C1 at start, M-C2 while preparing, M-C3 when a target report appears, M-C4 per failing check, M-C5 at `cutting/admitted` with the budget, M-C6 at `landing/probing`, M-C7 at the open with `opened_at - cut_at`, M-C8 at `rolling_back`, M-C9 at `rolled_back` clean, M-C10 while the target owes evidence past the release deadline, M-C11 at `stuck`, M-C12 at `ready` with `cut == manual`, M-C13 for an abort refused after the cut). The terminal result is **never inferred from the last state seen**: when the answer is `state: none`, the CLI reads `last` (`Record.LastMove`, index D11) and requires `last.id == moveID`; `done` → 0 with M-C7 (`offline_s` from the outcome), `rolled_back` → 3 with M-C9 (or M-C8 and M-C9 when it never saw `rolling_back`), `aborted` → 3; `stuck` and `rolling_back` past the release deadline → 4; a `none` whose `last.id` is another move, or no `last`, is "this move's outcome is not recorded" and exits 4 (Review Focus 1). A disconnected CLI resumed with `carlos move status --host <h> --wait` reads the same outcome.
- `--declare-lost`: before calling the door, the CLI verifies the box through the operator's own cloud credentials: `ec2.DescribeInstances` for the instance id the operator passes (or the box's instance id from `GET /api/cli/status/instances` if that door lists it); the state must be `stopped` or `terminated` (§7.10, model 5); it prints M-C18 and reads the host name from stdin as the confirmation; then `POST …/move/resolve {"declare_lost": box, "confirm": host, "cloud": {"instance_id", "state", "observed_at", "region", "account", "request_id"}, "destroyed_storage": [<volume ids the operator attests destroyed, optional>]}` (every `cloud` field is what Plan 8's `LossCloudObservation` requires: `account` is the `DescribeInstances` reservation's `OwnerId`, `request_id` the response's request id). Without AWS credentials the verb refuses: "declare-lost needs your AWS credentials to confirm the box is stopped or terminated". The verb is BLOCKED-ON-CLAIM with Plan 4 Task 10 (C6) and refused in production until Plan 8b (D13's switch does not open it).

Tests: `TestMoveWaitExitCodes` (a fake console serving a scripted sequence of detail answers: preparing → ready → cutting → landing → rolling_back → none with `last{id, rolled_back}`; the CLI exits 3 and printed M-C8 and M-C9; the done sequence exits 0 with M-C7; stuck exits 4; a 409 at the request exits 5); `TestMoveWaitSurvivesMissedStates` (Review Focus 1: the sequence preparing → none with `last{id, rolled_back}`, every intermediate state skipped: exit 3 with M-C8 and M-C9 printed from the outcome; none with `last.id` of another move → exit 4 with the "not recorded" sentence); `TestMoveManualCutPrintsM12AndStops`; `TestMoveDeclareLostNeedsStoppedOrTerminated` (witness `wDeclareLostCompute`: a fake EC2 answering `running` → refused before any console call).

- [ ] **Steps 1 to 5**. Commit: `git commit -m "carlos move: request, status, cut, abort, resolve; --wait and the exit codes (§12)" ...`

---

### Task 2: `detail=1` and the status door's shape

**Files:** modify `internal/console/apiinstancemove.go`, `internal/console/apiinstancemove_test.go`.

**Interfaces:**
- `moveView` gains `Last *provision.MoveOutcome \`json:"last,omitempty"\`` (index D11: `{"state":"none","last":{"id":"mv_…","outcome":"rolled_back","at":"…","offline_s":41.2}}` after a close; every caller of the flat view sees it, M18's Instance-role token included, since it reveals nothing the state did not); `moveDetail{moveView; Move *provision.MoveState; Claim struct{Kind, Phase, Exec string; Participants []string}; Target *moves.TargetReport; Source *moves.SourceReport (env HMACs only); Evidence struct{Source, Target string} (keys); Log []string (the last 20 log keys)}`; `GET …/move?detail=1` is operator-only beyond the flat view (an Instance-role token gets the flat view with no detail, M18).
- `handleAPIInstanceMoveStatus` reads `detail`; reads `hostclaim.Read`, the two reports and `s.cfg.Releases.List(moves.ReportPrefix(...)+"log/")`. A record in `relocating` (spec rev 7.20 §7.10, index D16) answers `state: relocating` and, when the claim is `relocating/pinning` with exec `"none"` and no live enrolled box in the source region, `detail.waiting_for_box = <region>`; the CLI prints M-C22 for it and `--wait` exits 4 (waiting). Test `TestMoveDetailShowsWaitingForBox`.

Tests: `TestMoveDetailLeaksNothing` (Review Focus 2: the response body contains no value of the launch environment, no evidence body, no `CARLOS_` value; it contains the report's check ids and the evidence keys); `TestMoveDetailIsOperatorOnly`.

- [ ] Commit: `git commit -m "console: GET …/move?detail=1 with phases, reports, evidence keys and the log (§12)" ...`

---

### Task 3: The move log and member events

**Files:** create `internal/moves/log.go`, `internal/moves/log_test.go`, `internal/host/movesevents.go`, `internal/host/movesevents_test.go`; modify the phase writers in `movescut.go`, `movesland.go`, `movesrollback.go`, `movesrecover.go`, `movesrevert.go`, `movesparks.go`, `internal/console/apiinstancemove.go` (request, cut, abort, resolve) to call `w.logEvent(ctx, rec, event, fields)`.

**Interfaces:**
- `moves.LogEvent{V int; Op, Host, Event, Actor string; At time.Time; Fields map[string]string}`; `moves.WriteLog(ctx, s release.Store, prefix string, ev LogEvent, identity string) error`: the key is the **transition's identity**, not a counter (round-2 finding 23): `log/<identity>.json` where `identity` is `<record state>-<phase>` for a phase transition (`cutting-stopped`, `landing-probing`, `rolling_back-source-claimed`), `probe_attempt-<n>` for the n-th attempt (`Landing.Attempts` or the recovery journal's counter), `stuck-<n>` and `retry-<n>` for the n-th entry into `stuck` (`Rollback.StuckCount`, a record field this task adds), and the terminal `done`, `rolled_back`, `aborted`, `declared_lost`; `s.Put(key, body, "")` is create-only (`store.go:38`), so two actors writing the same transition collide harmlessly on identical content and a replayed phase writes nothing new; the log is ordered by `At` and by the record's own phase order, never by a sequence number. `moves.ReadLog(ctx, s, prefix) ([]LogEvent, error)` lists `log/` and sorts by `At`.
- Events: `requested, ready, cut_requested, admitted, stopping, stopped, staged, archived, flipped, checking, parking, building, published, local, configured, restored, probing, probe_attempt, probed, committed, opened, source_parked, done, reverting, rev_evidence, rev_verify, rev_probing, rev_opened, rolling_back, released, returning, returned, claimed, verified, recovered, cleaning, cleaned, rolled_back, stuck, retry, aborting, aborted, declared_lost`; `Fields` carry timings (`offline_s` at `opened`/`recovered`: `cut_at` to the open) and digests.
- The member stream: `emitEvent(sink, rt, level, msg)` per transition with the operator sentence (M-C1, M-C5, M-C6, M-C7, M-C8, M-C9, M-C11 as member-safe lines); `movesWorker` gets `Events activator.EventSink` from `agent.go:464`.

Tests: `TestLogKeysAreTransitionIdentities` (two writers of `landing-probing` produce one object; three probe attempts produce three `probe_attempt-<n>` objects; a replayed `cutting-stopped` writes nothing new); `TestEveryTransitionEmitsOneEvent` (Review Focus 3: the harness counts events across a full done flow against the transition list; a status poll emits none).

- [ ] Commit: `git commit -m "moves: the move log and member-stream events (§13)" ...`

---

### Task 4: Timings

**Files:** modify `internal/moves/log.go`, `internal/host/movesparks.go`, `internal/host/movesrecover.go`.

- `moves.Timings(m *provision.MoveState) map[string]float64` (prepare: requested to ready; cut: cut_at to flipped; land: flipped to opened; offline: cut_at to opened, or to the source's reopen after a rollback; each against its budget: `cut_budget_s`, the landing deadline, the release deadline); written into the `done` and `rolled_back` log events' `Fields` and shown by `detail=1`.
- Test: a fixture journal yields the expected seconds.

- [ ] Commit: `git commit -m "moves: timings against budgets in the terminal log events (§13)" ...`

---

### Task 5: Paging rules

**Files:** create `internal/host/movespaging.go`, `internal/host/movespaging_test.go` (the primitive `host.PageOperator` is Plan 5 Task 0).

**Interfaces:**
- Rules (§13; a move converted to `relocating` by a termination fence with no live box in its region → incident, once, M-C22; spec rev 7.20): `rolled_back` → warning once; `stuck` → incident once per entry; `rolling_back` past `release_deadline` → incident once per hour; a refused start check or a refused stop evidence (stray process, M-C20) → incident once per op; `preparing` with no promotable round for 60 minutes → warning once. State for rate limits lives in the op journal (`Journal.Steps["paged:<rule>"]`), so a restart does not page again.

Test `TestPagingIsRateLimited` (Review Focus 4).

- [ ] Commit: `git commit -m "host: paging for moves through the alert sinks, rate-limited per op (§13)" ...`

---

### Task 6: The CLI page's fenced examples

**Files:** modify `docs/site/cli.md` (the `### carlos move` section Task 1 added).

- Every fenced `carlos move …` example in the section runs a real verb and real flags (`TestFencedCarlosCommandsAreReal`); the exit codes table and the `--declare-lost` confirmation paragraph are checked by reading them back in `TestMoveDocsNameEveryVerb` (a test in `move_test.go` that parses the section and asserts the five verbs and the four exit codes appear).

- [ ] Commit: `git commit -m "docs: carlos move examples and exit codes on the CLI page" ...`

---

### Task 7: Completing the copy record

**Files:** modify `docs/superpowers/plans/2026-10-06-moves-phase1-copy.md`, `approved_copy_test.go` (`movesCopyIDs`).

- The gate itself is Plan 1 Task 15 (the pinned inventory `movesCopyIDs`, `TestMovesCopyRecordIsComplete`, `TestMovesApprovedCopyIsApplied`, `TestPendingCopyIsNotWired` and its negative fixtures). This task adds this plan's rows (the CLI sentences, the detail door's refusals, the paging subjects) to the record and to `movesCopyIDs`, and confirms every row names the file that carries it.
- The review itself is Paul's: this task completes the record; it approves nothing. Until Paul approves, every string stays behind the gate, so the implementer wires strings only as they are approved (Review Focus 5 is pinned by Plan 1 Task 15's tests, which this task's rows extend).

- [ ] Commit: `git commit -m "copy record for the moves series completed (§20)" ...`

---

### Task 8: Retire the legacy move path, then the gate

Only after Paul approves the copy record and the switch's default flips to on (one commit, Plan 1 Task 15's rule): delete the legacy door handlers (`legacyHandleAPIInstanceMove…`), the legacy worker (`legacyMoveRound` and its tests), `promoteStagedReplica` and `ExecBackend.Phase1`, `provision.MoveRequest`, `MoveComplete`, the unguarded `MovePromoteReady`, `MoveMarkCutting`, `MoveAbort`, `MoveAbortComplete` and their `provision_test.go` tests, and `activator.MovesPrefix`/`MoveReceiptsKey` if nothing else uses them. v0.22 consoles keep refusing every new state through P0's allowlist (Plan 7 Task 2's test).

- [ ] Full gate, cross-compile (`move.go` is in the CLI binary: no Linux-only code).

## Self-review notes

Coverage: §12 API and CLI (Tasks 1, 2), §13 log, timings, paging, member stream (Tasks 3, 4, 5), §20 (Task 7), the docsite rule (Task 6), §7.10's CLI form and compute-fencing check (Task 1). Not here: the owner surface (F1), `carlos move back` (F2).
