carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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 → 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:

  • moveView gains Last *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).
  • 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: 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 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).