carlos / platform Public
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
carloscommand is not done untildocs/site/cli.mdnames it under a### carlos moveheading, every fenced example runs a real verb, andinternal/docsite's tests pass (AGENTS.md). - Exit codes: 0 done, 3 rolled back, 4 stuck or waiting, 5 refused (§12).
--waitis the default;--no-waitreturns 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
carlos move --waiton 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).GET …/move?detail=1must never inline evidence bodies or secrets: keys only, and the env report's HMACs only. Test in Task 2 (TestMoveDetailLeaksNothing).- The member stream must carry one event per transition and none for a poll. Test in Task 3.
- A paging rule that fires every pass would flood:
rolling_backpast the release deadline pages once an hour,preparingwith no promotable round pages once,stuckpages once per entry intostuck. Test in Task 5 (TestPagingIsRateLimited). - 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-verbsstatus,cut,abort,resolve --retry | --declare-lost <box> [--instance-id <id>] [--region <aws region>];--hostresolves the account and app throughresolvedAppwhen--appis absent (the host's record is found throughGET /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, asinstances_cli.go's host resolution does if it exists; else--appis required with a sentence saying so).moveWait(ctx, sess *apiSession, path, moveID string, out io.Writer, poll time.Duration) intpollsGET <path>?detail=1everypoll(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 atcutting/admittedwith the budget, M-C6 atlanding/probing, M-C7 at the open withopened_at - cut_at, M-C8 atrolling_back, M-C9 atrolled_backclean, M-C10 while the target owes evidence past the release deadline, M-C11 atstuck, M-C12 atreadywithcut == manual, M-C13 for an abort refused after the cut). The terminal result is never inferred from the last state seen: when the answer isstate: none, the CLI readslast(Record.LastMove, index D11) and requireslast.id == moveID;done→ 0 with M-C7 (offline_sfrom the outcome),rolled_back→ 3 with M-C9 (or M-C8 and M-C9 when it never sawrolling_back),aborted→ 3;stuckandrolling_backpast the release deadline → 4; anonewhoselast.idis another move, or nolast, is "this move's outcome is not recorded" and exits 4 (Review Focus 1). A disconnected CLI resumed withcarlos move status --host <h> --waitreads the same outcome.--declare-lost: before calling the door, the CLI verifies the box through the operator's own cloud credentials:ec2.DescribeInstancesfor the instance id the operator passes (or the box's instance id fromGET /api/cli/status/instancesif that door lists it); the state must bestoppedorterminated(§7.10, model 5); it prints M-C18 and reads the host name from stdin as the confirmation; thenPOST …/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>]}(everycloudfield is what Plan 8'sLossCloudObservationrequires:accountis theDescribeInstancesreservation'sOwnerId,request_idthe 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 → rollingback → none with `last{id, rolledback}; 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:
moveViewgainsLast *provision.MoveOutcome \json:"last,omitempty"\`(index D11:{"state":"none","last":{"id":"mv…","outcome":"rolledback","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).handleAPIInstanceMoveStatusreadsdetail; readshostclaim.Read, the two reports ands.cfg.Releases.List(moves.ReportPrefix(...)+"log/"). A record inrelocating(spec rev 7.20 §7.10, index D16) answersstate: relocatingand, when the claim isrelocating/pinningwith exec"none"and no live enrolled box in the source region,detail.waiting_for_box = <region>; the CLI prints M-C22 for it and--waitexits 4 (waiting). TestTestMoveDetailShowsWaitingForBox.
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>.jsonwhereidentityis<record state>-<phase>for a phase transition (cutting-stopped,landing-probing,rolling_back-source-claimed),probe_attempt-<n>for the n-th attempt (Landing.Attemptsor the recovery journal's counter),stuck-<n>andretry-<n>for the n-th entry intostuck(Rollback.StuckCount, a record field this task adds), and the terminaldone,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 byAtand by the record's own phase order, never by a sequence number.moves.ReadLog(ctx, s, prefix) ([]LogEvent, error)listslog/and sorts byAt.- 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;Fieldscarry timings (offline_satopened/recovered:cut_atto 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);movesWorkergetsEvents activator.EventSinkfromagent.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: cutat to flipped; land: flipped to opened; offline: cutat 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 thedoneandrolled_backlog events'Fieldsand shown bydetail=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
relocatingby 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_backpastrelease_deadline→ incident once per hour; a refused start check or a refused stop evidence (stray process, M-C20) → incident once per op;preparingwith 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-lostconfirmation paragraph are checked by reading them back inTestMoveDocsNameEveryVerb(a test inmove_test.gothat 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,TestPendingCopyIsNotWiredand 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 tomovesCopyIDs, 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.gois 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).