carlos / platform Public

docs: the Phase 1 rehearsal runbook in M12's order (§14.4, §15)

Written against main per the Task 3 rulings: every command from move.go
with --app moveprobe --account <ops>, every event from transition.go,
and each check naming its read path (the CLI's sentence and exit code,
detail=1, the deployment or data bucket with a named profile, the probe's
report, or the box). P-0 lists what every run waits for (claim Plan 12,
its moves dependants, the cut guard, the home parks fix, all released
and rolled); each run states its blockers at its top. Rehearsal hosts are
created before the claim and never reused; dev's second region and home
ordering, the flagship's env switch on non-home regions, and neutral
wording for the customer runs follow Paul's rulings. A Sources appendix
cites file:line for each fact.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 54f693314a2292101a54cb2c187c2ef2982b4ffd parent 1de7481
1 file changed, +803 −0
  • docs/superpowers/plans/2026-10-06-moves-phase1-rehearsal.md +803 −0
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-rehearsal.md b/docs/superpowers/plans/2026-10-06-moves-phase1-rehearsal.md
new file mode 100644
index 000000000..4d12a9359
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-rehearsal.md
@@ -0,0 +1,803 @@
+# Moves Phase 1: the rehearsal runbook
+
+This is the checklist an operator follows to rehearse Phase 1 moves (spec
+§14.4, rollout §15). It covers seven runs in the spec's order: four on dev,
+one on the flagship with the probe app, one on a disposable customer-shaped
+instance, and one on a real customer instance.
+
+**No run in this runbook can execute today.** Every run waits for P-0
+below. Each run also has its own blockers, listed at its top. Never run one
+without Paul's go in the amadan thread.
+
+The probe app is `moveprobe`. How to build it, ship it and use it is in
+[`cmd/moveprobe/README.md`](../../../cmd/moveprobe/README.md). This runbook
+does not repeat it.
+
+Every command, flag, event, field and key below was checked against main,
+and moveprobe's against this branch.
+The Sources section at the end gives file and line for each.
+
+---
+
+## How to read this runbook
+
+### Placeholders
+
+| placeholder | what it is |
+|---|---|
+| `<console>` | the deployment's console origin, `https://...`, as `carlos auth login` stored it |
+| `<ops>` | the ops account's sqid, as `--account` takes it |
+| `<host>` | the run's rehearsal host, `<label>.<instance domain>` |
+| `<deploy-bucket>` | the deployment bucket (`CARLOS_DEPLOYMENT_BUCKET` in a box's `/etc/carlos/host.env`) |
+| `<data-bucket-R>` | region R's data bucket: `data_bucket` in that region's box edges file |
+| `<profile>` | the deployment's named AWS profile. Always name one. There are no default credentials. |
+| `<move>` | the move id the console gave the request (`id` in the flat view) |
+| `<inc>` | the instance incarnation (`detail.move.incarnation`) |
+
+### Read paths
+
+Each check says where to read it.
+
+- **[CLI]**: the sentence `carlos move` prints and its exit code. Exit codes:
+ 0 done; 3 rolled back or aborted; 4 waiting (ready for a manual cut,
+ stuck, a release past its deadline, relocating, or an outcome not
+ recorded); 5 refused; 1 the console could not be reached or read; 2 a
+ usage error.
+- **[detail]**: the operator's detail answer. No `carlos` command prints it
+ yet, so read it with your own login token:
+
+ ```sh
+ CONSOLE=<console>
+ TOKEN=$(jq -r --arg c "$CONSOLE" '.credentials[] | select(.console == $c) | .token' ~/.carlos/credentials)
+ detail() { curl -fsS -H "Authorization: Bearer $TOKEN" "$CONSOLE/api/cli/apps/<ops>/moveprobe/instances/$1/move?detail=1"; }
+ detail <host> | jq .
+ ```
+
+ The flat fields (`state`, `phase`, `id`, `last`) sit at the top. The
+ rest is under `detail`: `move` (the record's journal, absent once the
+ move closes), `claim`, `target_report`, `source_report`, `evidence`,
+ `log` (keys only), `timings` (after the close) and `region`.
+- **[deploy]**: the deployment bucket, read with `--profile <profile>`.
+ Each move keeps its reports, stop evidence and log under
+ `moves/<ops>/moveprobe/<host>/<move>/`: `source.json`, `target.json`,
+ `stopped-source.json`, `stopped-target.json` and `log/<identity>.json`.
+ The claim is `control/hosts/<host>.json`, the roster
+ `control/host-actors.json`, each box's edges file
+ `control/edges/<box>.json`, each box's identity record
+ `control/boxes/<box>.json`, and the floor `control/moves-protocol.json`.
+
+ ```sh
+ P="moves/<ops>/moveprobe/<host>/<move>/"
+ aws s3 ls --profile <profile> "s3://<deploy-bucket>/${P}log/"
+ aws s3 cp --profile <profile> "s3://<deploy-bucket>/${P}log/done.json" - | jq .
+ ```
+
+- **[data]**: a region's data bucket, read with `--profile <profile>`. The
+ instance's replica is under `instances/<host>/` (the box's
+ `CARLOS_HIBERNATE_PREFIX`, `instances` in production), a move's staging
+ lane under `moves/<host>/<move>/`, and parked objects under
+ `parked/<inc>/<host>/<retirement>/` with a `manifest.json`. These buckets
+ are versioned, so list versions, not objects:
+
+ ```sh
+ aws s3api list-object-versions --profile <profile> --bucket <data-bucket-R> --prefix "instances/<host>/"
+ ```
+
+- **[report]**: moveprobe's own report, with the secret from your shell:
+
+ ```sh
+ curl -fsS -H "Authorization: Bearer $MOVEPROBE_SECRET" https://<host>/report | jq .
+ ```
+
+- **[box]**: on a box, the agent's journal (`journalctl -u carlos-edge`),
+ `/etc/carlos/host.env`, and files under the data directory (`/data`).
+
+### Log events
+
+Each transition writes one log object, keyed by its identity. The ones the
+checks use:
+
+| identity (the key) | event (the `event` field) | when |
+|---|---|---|
+| `requested` | `requested` | the request was journaled |
+| `ready` | `ready` | the target's checks passed |
+| `cutting-requested` | `cut_requested` | the cut was asked for |
+| `cutting-<phase>` | `<phase>` | `admitted`, `stopping`, `stopped`, `staged`, `archived` |
+| `cutting-flipped` | `flipped` | the record now names the target |
+| `landing-<phase>` | `<phase>` | the target's landing steps |
+| `probe_attempt-<n>` | `probe_attempt` | the landing's n-th readiness probe |
+| `committed` | `committed` | the landing committed |
+| `committed-opened` | `opened` | the target opened (`offline_s` in its fields) |
+| `committed-source_parked` | `source_parked` | the source parked what it held |
+| `rolling_back` | `rolling_back` | a rollback started (`reason` in its fields) |
+| `rolling_back-target-<phase>` | `<phase>` | `released`, `returning`, `returned` (a dirty rollback only), `cleaning`, `cleaned` |
+| `rolling_back-target-lost` | `declared_lost` | the target was declared lost |
+| `rolling_back-source-<phase>` | `<phase>` | the source's recovery, up to `recovered` and `opened` |
+| `stuck-<epoch>`, `retry-<epoch>` | `stuck`, `retry` | a stuck move and its retry |
+| `done`, `rolled_back`, `aborted` | the same | the close; `done` and `rolled_back` carry the timings |
+
+Every entry's `fields` carry `from`, `to` and, once known, `final_txid`,
+`db_sha256`, `timers_sha256`, `files_plain`, `installed_db_sha256`,
+`release`, `env_report` and `cut_budget_s`. The close's timings are
+`prepare_s`, `cut_s`, `cut_budget_s`, `land_s`, `land_budget_s`,
+`offline_s`, `rollback_s` and `release_budget_s`. There is no `stray`
+event and no `dirty` field in the log.
+
+---
+
+## P-0: what every run waits for
+
+Nothing below P-0 runs until every item here is true. Paul confirms P-0 per
+deployment before its first run.
+
+- [ ] **Claim Plan 12 merged.** Without it the landing, the target's half
+ of a rollback and the source's recovery are unarmed in production, so a
+ move with the switch on stops the source, flips, and then has nothing to
+ land it, roll it back or recover it.
+- [ ] **Moves Plan 5 Task 9b, Plan 4 Tasks 9 and 10 (the box half), and
+ Plan 5 Task 8 merged.** They wire moves onto claim Plan 12. Run 4 also
+ needs claim Plan 12's loss-bound admission.
+- [ ] **The moves cut guard merged** (branch `moves-cut-guard`, Paul's
+ ruling of 2026-10-11). The source refuses a cut unless the target
+ reports its landing armed. It adds a `T13` check to the target's report
+ and records why a cut did not start. It is not on main yet.
+- [ ] **For any run that touches the home region: the home-region parks fix
+ part 1 merged** (#236). Part 1 covers moves out of home. Moves into home
+ need part 2, and the door refuses them until then ("moves to the home
+ region are not supported yet. the home publishes no staging lane").
+- [ ] **All of the above released and rolled, consoles first, then every
+ box.** Check every box with `carlos system status --console <console>`:
+ each reads `current` on the new version.
+- [ ] **Each deployment's home region recorded:** `CONSOLE_HOME_REGION` in
+ the console's environment. A box with no `CARLOS_REGION` in its
+ `host.env` publishes no `placement` in its edges file: it is a home box.
+- [ ] **Rehearsal boxes keep instances flat.** Moves refuse a nested
+ instance. Set `CARLOS_LAYOUT_DEFAULT=flat` in each rehearsal box's
+ `host.env`. Check: `carlos system layout status --console <console>`
+ shows no nested rehearsal host, and before each run [detail]
+ `detail.source_report.s1.ok` is `true`.
+- [ ] **Never set `CARLOS_MOVES_PHASE1=on` anywhere before P-0 is true.**
+
+---
+
+## Preconditions, once per deployment
+
+### P-1 The dormant release
+
+- [ ] The release carrying P-0 is rolled with `CARLOS_MOVES_PHASE1` unset
+ everywhere: consoles first, then boxes.
+- [ ] [deploy] Every box's `control/edges/<box>.json` has
+ `"moves_protocol": 1`.
+
+### P-2 Staging grants (§15 step 5, scoped tofu by Paul)
+
+- [ ] Every rehearsal region's `sibling_staging_arns` lists every other
+ rehearsal region's data bucket, and each box has the versioned-object
+ grants (`s3:ListBucketVersions`, `s3:GetObjectVersion`,
+ `s3:DeleteObjectVersion` on its own bucket; `s3:GetObjectVersion` for
+ the console).
+- [ ] Check after the first request reaches ready: [detail]
+ `detail.target_report.checks.T10.ok` is `true` (the lane probe).
+
+### P-3 The protocol floor
+
+Publish the floor only after **every box and the console run a tagged
+`vX.Y.Z` release** at or above `min_release`. Once a floor is armed, any
+build that is not `vX.Y.Z` (a SHA build, `dev`, a prerelease) is refused at
+activation, and the door treats that box as below the floor. A box never
+lowers its local floor on its own. If dev keeps rolling SHA builds, dev's
+runs go without a floor, and P-3 is done on the flagship only, before
+Run 5 (§15 step 6).
+
+- [ ] Write it create-only from an operator shell. No `carlos` command
+ writes it (a gap to note on an amadan discussion).
+
+ ```sh
+ printf '{"min":1,"min_release":"vX.Y.Z"}' > floor.json
+ aws s3api put-object --profile <profile> --bucket <deploy-bucket> \
+ --key control/moves-protocol.json --body floor.json --if-none-match '*'
+ aws s3 cp --profile <profile> s3://<deploy-bucket>/control/moves-protocol.json -
+ ```
+
+- [ ] [deploy] Every box's edges file shows `moves_floor_min_release` equal
+ to the floor's `min_release`, and either `"prev_release": "none"` or
+ `prev_allowed_for` equal to `min_release`. A box that does not is below
+ the floor for the door (MV-N5).
+
+### P-4 Ship moveprobe
+
+- [ ] Follow `cmd/moveprobe/README.md` ("Build" and "Deploy") under the ops
+ account: ship, enable instances with private storage off, create the
+ store with `--env-prefix MOVEPROBE` and no `--environment`, set
+ `MOVEPROBE_SECRET`, and set the `every-minute` schedule.
+- [ ] Keep `MOVEPROBE_SECRET`'s value in your shell. `/report` and
+ `/arm-once` need it.
+- [ ] Leave `moves.ready_path` unset. A move then probes `/`, which answers
+ as `/ready` does.
+
+### P-5 Rehearsal hosts, created before the claim
+
+A host created while `CARLOS_HOST_CLAIM` is on keeps its data under a
+per-incarnation path, and the move door refuses it with MV-N167: "the
+instance keeps its data under a per-incarnation path, which moves cannot
+cut yet". So every rehearsal host is created **before** the claim goes on
+for its deployment (Paul, N2(a)).
+
+- [ ] Before `CARLOS_HOST_CLAIM` goes on, create one moveprobe host per
+ planned run, in the region that run starts from, plus two spares:
+
+ ```sh
+ carlos instances create --app moveprobe --account <ops> --host <label>.<instance domain> --region <region>
+ ```
+
+ On the flagship, also create Run 6's disposable customer-shaped
+ instance now.
+- [ ] Record the list of hosts, with the run each one is for, in the
+ deployment's rehearsal record (outside this repo). **Never reuse a
+ host.** A host that has run once is spent, whatever the outcome.
+- [ ] After the claim is on, check each host is adopted: [deploy]
+ `control/hosts/<host>.json` exists, `holder.location` is
+ `instances/<host>` (not `instances/<host>/<inc>`), and `op.kind` is
+ `serving`. The console's claim recovery pass adopts them.
+
+### P-6 The rest of the fleet's preconditions
+
+Paul confirms each of these per deployment before its first run.
+
+- [ ] **Claim on.** `CARLOS_HOST_CLAIM` is set (any non-empty value) in
+ the console's environment and every box's `host.env`. Check: [box] on
+ each box and the console. #250's activation blockers are closed.
+- [ ] **Roster.** Each rehearsal box is an active, enrolled entry with no
+ termination fence. Check: [deploy] `control/host-actors.json`
+ `boxes.<box>.state` is `active` and `boxes.<box>.enrollment` is set.
+- [ ] **Box identity records.** `CARLOS_REGION_HA=true` in each rehearsal
+ box's `host.env`. It takes `true`, `1` or `t`; **`on` reads as off**.
+ `CARLOS_AWS_ACCOUNT` is set in the console's environment. Check: [deploy]
+ `control/boxes/<box>.json` exists for each rehearsal box, and its edges
+ file has `"capabilities": ["region-ha-1"]`.
+- [ ] **One eligible box per rehearsal region.** Any other box in a
+ rehearsal region runs without `CARLOS_REGION_HA`, so it publishes no
+ `region-ha-1` and is not eligible. Otherwise the door answers M-C21.
+- [ ] **Secrets key.** `CARLOS_SECRETS_KEY` on every rehearsal box, from
+ the same key pair as the console's. Check: [deploy] each edges file's
+ `secrets_key_fp` is the same; once the switch is on, it equals
+ `secrets_key_fp` in `control/moves-console.json`; [detail]
+ `detail.target_report.checks.T2.ok` is `true`.
+- [ ] **Staging bucket.** Each rehearsal box's edges file has
+ `data_bucket` set.
+- [ ] **Alert sinks.** `CARLOS_ALERT_WEBHOOK` or
+ `CARLOS_ALERT_SNS_TOPIC_ARN` is set on every rehearsal box and on the
+ console. Without one, pages go nowhere.
+- [ ] **Cgroup delegation.** Check: [box] `journalctl -u carlos-edge -b |
+ grep 'exec-child accounting'` shows `armed (delegated subtree ...)`. A
+ box without it cannot produce stop evidence and never cuts.
+- [ ] **Flat rows and tagged releases**, as P-0 and P-3 say.
+
+### P-7 The switch
+
+- [ ] Only after P-0 to P-6: `CARLOS_MOVES_PHASE1=on` in the environment
+ of the console and of each box in play, then restart them.
+- [ ] Check: `carlos move status --app moveprobe --account <ops> --host
+ <host>` does not say "Moves are not enabled on this console."
+
+---
+
+## Between runs
+
+Before starting the next run, wait until all of these hold for the host
+the last run used:
+
+- [ ] [detail] `state` is `none` (no move in flight) and `last.outcome` is
+ recorded.
+- [ ] [detail] `detail.claim.kind` is `serving`.
+- [ ] [deploy] the claim's `quiet_after` for the region the next move
+ goes to has passed (T12). It is set when an instance leaves a region and
+ lasts about 16 minutes. With a fresh host per run it rarely bites, but
+ check it.
+- [ ] `MOVEPROBE_FAIL_READY_IN` is unset: `carlos env list --app moveprobe
+ --account <ops>` does not show it.
+
+---
+
+## The steps and checks each run uses
+
+Runs refer to these by number. `<from>` and `<to>` are the run's regions.
+
+### Steps
+
+- **S-1 Wake and record.** `curl https://<host>/ready` answers `ready`.
+ Save [report] as `before-request.json`.
+- **S-2 Request.**
+
+ ```sh
+ carlos move --app moveprobe --account <ops> --host <host> --to <to> --manual-cut
+ ```
+
+ [CLI] M-C1 ("Moving <host> from <from> to <to>."), M-C2, M-C3, then
+ M-C12 ("Ready to cut. ..."), exit 4. A line "<to> cannot take this move
+ yet: <check>." is M-C4 and names the failing check.
+ [detail] at ready: every `detail.target_report.checks.T1` to `T12` has
+ `ok: true` (and `T13` once the cut guard lands);
+ `detail.target_report.attempts_admitted` is at least 1;
+ `detail.source_report.s1.ok` and `s2.ok` are `true`. Record `id` as
+ `<move>` and `detail.move.incarnation` as `<inc>`.
+- **S-3 Record and arm.** Save [report] as `before-cut.json`. Arm the
+ once-timer so it falls due around the cut, then cut within a few
+ seconds:
+
+ ```sh
+ curl -fsS -X POST -H "Authorization: Bearer $MOVEPROBE_SECRET" "https://<host>/arm-once?in=30"
+ ```
+
+ Record the answer's `name` and `due_at`.
+- **S-4 Cut.**
+
+ ```sh
+ carlos move cut --app moveprobe --account <ops> --host <host>
+ ```
+
+ The expected sentences and exit code are in each run.
+
+### Checks after a commit
+
+- **R-1 State arrived.** [report] on the target: `region` is `<to>`;
+ `file_key_sha256` and `platform_key_sha256` equal `before-cut.json`'s;
+ `object` has no `error` and its `body` names this boot and `<to>`;
+ `has_secret` is `true`. `curl https://<host>/ready` answers `ready`.
+- **R-2 No row lost.** [report] for `bg` and for `worker`: the sum of
+ `count` over all regions is at least `before-cut.json`'s, and equals the
+ highest `max_seq` (rows are numbered from 1 in one sequence per kind, so
+ any gap shows here).
+- **R-3 The schedule continues.** [report] `rows` has `kind: tick` with
+ `region: <to>`, and its `count` rises about once a minute for three
+ minutes after the open. Every `fired` entry has `count: 1`. A minute
+ that fell due while the instance was offline fires at most once after
+ the open, and minutes in between are not replayed, so a short gap
+ around the cut is expected. A due time that fired twice fails the run.
+- **R-4 The once-timer fired once.** [report] `fired` has exactly one
+ entry with S-3's `name`, its `due_at` equals S-3's, and its `count` is 1.
+- **R-5 The source holds nothing live.** After [deploy]
+ `log/committed-source_parked.json` and `log/done.json` exist:
+ [data] `list-object-versions` on `<data-bucket-from>` under
+ `instances/<host>/` returns no versions and no delete markers, and
+ `parked/<inc>/<host>/` holds the retired replica objects with a
+ `manifest.json`. [deploy] the claim's `obligations` is empty.
+- **R-6 The source really stopped.** [deploy] `stopped-source.json` (also
+ named by [detail] `detail.evidence.source` while the move is in flight)
+ has `steps.tree.ok` and `steps.stray.ok` both `true` and no `stray`
+ field. [box] on the source box, the journal has no "the instance is not
+ proven stopped yet" line for `<host>` and no M-C20 line ("... is running
+ a process for <host> that this move cannot account for. ..."), and
+ `/data/.moves/.page-outbox/<host>/<move>/stray.json` does not exist.
+ The moveprobe worker outlives its app on purpose, so a worker that
+ survived the stop fails the cut's tree step and shows here, then
+ reverts at the cut's deadline. It does not page.
+
+### Checks after a rollback (Runs 2, 3 and 4)
+
+R-1 to R-4 and R-6 apply as written, with `<from>` (the source, where the
+instance is back) in place of `<to>`. R-5 is replaced:
+
+- **R-5b The target holds nothing live.** After [deploy]
+ `log/rolling_back-target-cleaned.json` and `log/rolled_back.json` exist:
+ [data] `list-object-versions` on `<data-bucket-to>` under
+ `instances/<host>/` and under `moves/<host>/<move>/` returns no versions
+ and no delete markers.
+- **R-7 The target's writes came back** (dirty rollbacks only). [report]
+ on the source: `rows` has a `bg` row with `region: <to>`, written on the
+ target while it ran there.
+
+A clean rollback needs two things: the stopped target's digests equal
+what the landing installed, and `src_covered` (the source's own replica
+reached the stopped position). The target box logs both:
+[box] `journalctl -u carlos-edge | grep 'the target of rollback'` shows
+"... stopped at TXID <n> (clean <bool>, src_covered <bool>)".
+
+---
+
+## Dev (Runs 1 to 4)
+
+**Dev topology (Paul, N4).** Dev's second region is applied: `uk`,
+`eu-west-2`, box `carlos-dev-uk`, data bucket
+`carlos-dev-uk-640012953448` (platform-infrastructure#42). Before Run 1:
+
+- [ ] Bootstrap `carlos-dev-uk`.
+- [ ] Copy the SSM parameters a dev box reads at boot to `eu-west-2`.
+- [ ] `CARLOS_REGION_HA=true` on `carlos-dev-ie` and `carlos-dev-uk`, and
+ `CARLOS_AWS_ACCOUNT` on the dev console.
+- [ ] Keep `carlos-dev-ie-b` ineligible: no `CARLOS_REGION_HA` in its
+ `host.env`, so `ie` has one eligible box (M-C21).
+- [ ] Create the dev rehearsal hosts (P-5) after `carlos-dev-uk` serves and
+ before dev's claim goes on.
+
+**Dev's home region.** Dev Ireland is probably the home region (its box
+has no `CARLOS_REGION`). Confirm it with the dev console's
+`CONSOLE_HOME_REGION` and record it here: `____`. If `ie` is home:
+
+- Run 1 (`ie` to `uk`) moves out of home and needs **home fix part 1**.
+- Run 4 (`ie` to `uk`) moves out of home and then rolls back into it. It
+ needs **part 1**, and part 1 must cover a rollback whose source is home.
+- Runs 2 and 3 as written (`uk` to `ie`) move into home and need **home
+ fix part 2**. The door refuses them until then.
+- **Until part 2 lands, only out-of-home moves run.** Run 1 goes ahead on
+ part 1. Runs 2 and 3 wait. If Paul wants them sooner, they can run out
+ of home (`ie` to `uk`, with the failure switch on `uk`), which rolls back
+ into a home source, as Run 4 does. Use that order, and run Run 4, only
+ if part 1's own tests show it covers a rollback whose source is home.
+ Part 1 is not built yet, so this is open.
+- On the home box, moveprobe sees its region as `home`, not `ie`. Any
+ `MOVEPROBE_FAIL_READY_IN` aimed at that box names `home`.
+
+If `ie` is not home, the runs go as written and need no home fix.
+
+### Run 1: dev, `ie` to `uk`, a commit
+
+**Blockers:** P-0; home fix part 1 if `ie` is home; dev topology above; P-1
+to P-7 on dev; Paul's go.
+
+- [ ] Host used: `____` (created in `ie` before the claim).
+- [ ] S-1, S-2 with `--to uk`.
+- [ ] S-3, S-4. [CLI] M-C5 ("uk is ready. Starting the cut. <host> will be
+ offline for about <estimate>."), M-C6 ("Stopped in ie. Starting in
+ uk."), M-C7 ("Moved. <host> is served from uk. It was offline for
+ <seconds> seconds."), exit 0.
+- [ ] R-1 to R-6.
+- [ ] [deploy] `log/` has `cutting-flipped`, `committed`,
+ `committed-opened`, `committed-source_parked` and `done`. [detail]
+ `last.outcome` is `done`; `detail.timings` holds `offline_s` and
+ `cut_budget_s`.
+
+### Run 2: dev, forced clean rollback
+
+**Blockers:** P-0; home fix **part 2** if `ie` is home (see above); Run 1
+passed; Paul's go.
+
+The target fails its readiness check in before-write mode, so it writes
+nothing and the rollback should be clean.
+
+- [ ] Host used: `____` (created in `uk` before the claim).
+- [ ] Before the request, set the switch for the target's region (`ie`, or
+ `home` if `ie` is home):
+
+ ```sh
+ carlos env set --app moveprobe --account <ops> MOVEPROBE_FAIL_READY_IN=ie
+ ```
+
+ The variable is app-wide. Any moveprobe instance that starts in that
+ region while it is set answers 503 and writes nothing. That is expected.
+- [ ] S-1, S-2 with `--to ie`, S-3, S-4. [CLI] M-C8 ("ie could not start
+ <host>: <reason>. Moving it back to uk."), then M-C9 ("<host> is back in
+ uk and serving. Nothing was lost."), exit 3.
+- [ ] While the move is rolling back and `detail.move.rollback.target_phase`
+ is `released` or later: [detail] `detail.move.rollback.dirty` is absent
+ or `false`. Record `detail.evidence.target`.
+- [ ] Digests: [deploy] `stopped-target.json`'s `db_sha256`, `files_plain`
+ and `timers_sha256` equal `log/rolling_back-target-released.json`'s
+ fields `installed_db_sha256`, `files_plain` and `timers_sha256`.
+- [ ] [box] on the `ie` box: "clean true, src_covered true".
+- [ ] R-1 to R-4 and R-6 on `uk`, R-5b on `ie`.
+- [ ] [deploy] `log/` has `rolling_back`, `rolling_back-target-released`,
+ `rolling_back-target-cleaned`, `rolling_back-source-opened` and
+ `rolled_back`, and no `rolling_back-target-returning`.
+- [ ] After the checks are recorded:
+
+ ```sh
+ carlos env unset --app moveprobe --account <ops> MOVEPROBE_FAIL_READY_IN
+ ```
+
+A dirty result here, with moveprobe in before-write mode, is a platform
+finding, not a probe failure. Stop the series and report it.
+
+### Run 3: dev, forced dirty rollback
+
+**Blockers:** P-0; home fix **part 2** if `ie` is home; Run 2 passed;
+Paul's go.
+
+The target writes one row, starts its worker, and then fails readiness, so
+the rollback is dirty and the target's writes must come back.
+
+- [ ] Host used: `____` (created in `uk` before the claim).
+- [ ] Before the request:
+
+ ```sh
+ carlos env set --app moveprobe --account <ops> MOVEPROBE_FAIL_READY_IN=ie,after-write
+ ```
+
+- [ ] S-1, S-2 with `--to ie`, S-3, S-4. [CLI] M-C8, then M-C9, exit 3.
+- [ ] [detail] while rolling back, once `target_phase` is `released` or
+ later: `detail.move.rollback.dirty` is `true`.
+- [ ] Digests: [deploy] `stopped-target.json`'s `db_sha256` differs from
+ `log/rolling_back-target-released.json`'s `installed_db_sha256` (the
+ target wrote).
+- [ ] [box] on the `ie` box: "clean false".
+- [ ] [deploy] `log/` has `rolling_back-target-returning` and
+ `rolling_back-target-returned`, then `rolling_back-target-cleaned` and
+ `rolled_back`.
+- [ ] R-1 to R-4 and R-6 on `uk`, R-5b on `ie`, and R-7.
+- [ ] Unset `MOVEPROBE_FAIL_READY_IN` once the checks are recorded.
+
+### Run 4: dev, declare-lost
+
+**Blockers:** P-0, including claim Plan 12's loss-bound admission and Plan
+4 Task 10's box half; home fix part 1 if `ie` is home; Run 1 passed (Runs 2
+and 3 too, if part 2 has landed); Paul's go, and his agreement to take
+`carlos-dev-uk` out of service for the run.
+
+The target box is stopped mid-move and declared lost. With only two dev
+regions, the target is `carlos-dev-uk`, and `uk` has no eligible box until
+the readmit at the end. **Stop the box. Do not terminate it.** A terminated
+box cannot be readmitted.
+
+- [ ] Host used: `____` (created in `ie` before the claim).
+- [ ] So the landing cannot commit before the box stops, set the failure
+ switch on the target first:
+
+ ```sh
+ carlos env set --app moveprobe --account <ops> MOVEPROBE_FAIL_READY_IN=uk
+ ```
+
+ Its probe then fails however long the stop takes. If the target finishes
+ its release before the box stops, the run is an ordinary clean rollback:
+ record it and repeat Run 4 on a spare host.
+- [ ] S-1, S-2 with `--to uk`, S-3, then
+ `carlos move cut --app moveprobe --account <ops> --host <host> --no-wait`.
+- [ ] When [detail] `state` is `landing` (after `cutting-flipped`), stop
+ `carlos-dev-uk` through the AWS console (dev only).
+- [ ] Wait for `carlos move status --app moveprobe --account <ops> --host
+ <host> --wait` to say M-C10 ("Waiting for carlos-dev-uk in uk to confirm
+ it has stopped <host>. <host> is offline."), exit 4. If it is still
+ landing, the landing deadline has not passed yet. Keep waiting.
+- [ ] Declare it lost, with your own AWS credentials for dev in the shell
+ (the command reads EC2 with them and refuses to run as root):
+
+ ```sh
+ carlos move resolve --app moveprobe --account <ops> --host <host> --declare-lost carlos-dev-uk
+ ```
+
+ [CLI] M-C18 ("This declares carlos-dev-uk lost. Anything <host> wrote
+ on carlos-dev-uk after <time> is lost. Type the host name to
+ confirm."). Type `<host>`. Expect M-C9 and exit 3.
+- [ ] Expected outcome: **rolled back, with the loss on the record**, not
+ necessarily clean. [detail] while rolling back:
+ `detail.move.rollback.loss_ref` and `lost_at` are set. [deploy]
+ `log/rolling_back-target-lost.json` (event `declared_lost`) and
+ `log/rolled_back.json` exist. [deploy] `control/host-actors.json`:
+ `boxes.carlos-dev-uk.state` is `quarantined` and its `loss_ids` holds
+ the loss.
+- [ ] R-1 to R-4 and R-6 on `ie`.
+- [ ] Unset `MOVEPROBE_FAIL_READY_IN`.
+- [ ] Start `carlos-dev-uk` again. It stays quarantined: the deployment's
+ admission gate refuses it for every host until the readmit. Watch [box]
+ its journal from boot, and record anything it does for `<host>` before
+ the readmit. Doing anything for it there is a finding.
+- [ ] Readmit it. `carlos ops host` exists only with `CARLOS_HOST_CLAIM`
+ set in your shell:
+
+ ```sh
+ CARLOS_HOST_CLAIM=1 carlos ops host resolve <host> --readmit carlos-dev-uk --console <console>
+ ```
+
+ Answer `readmit` at its prompt. The box must be running and reporting:
+ readmit refuses a box whose private report is older than 15 minutes.
+- [ ] [deploy] `boxes.carlos-dev-uk.state` is `active` again.
+
+---
+
+## Flagship (Run 5)
+
+### Run 5: the flagship, Runs 1 to 3 again
+
+**Blockers:** P-0; P-1 to P-6 on the flagship; **P-3, the floor, published**
+(§15 step 6: before any protected production rehearsal); the moves copy
+record fully approved (no `pending` row); dev Runs 1 to 3 passed; Paul's
+explicit go in the thread, for each of the three.
+
+- **The switch** (Paul, N17): `CARLOS_MOVES_PHASE1=on` in the environment
+ of the console and of the two boxes in play, and nowhere else. The
+ compiled default stays off. It flips later, in its own commit.
+- **Regions** (N3): the flagship's home is Ireland, so Run 5 uses two
+ non-home regions, `uk` and `us`, until the home fix parts land. Run 1's
+ shape goes `uk` to `us`. Runs 2 and 3 go `us` to `uk`, with
+ `MOVEPROBE_FAIL_READY_IN=uk` and `uk,after-write`.
+
+- [ ] Hosts used: Run 1 shape `____` (created in `uk`), Run 2 shape `____`
+ and Run 3 shape `____` (created in `us`), all before the flagship's
+ claim went on.
+- [ ] Each of the three as on dev, with every check.
+- [ ] After the third, remove `CARLOS_MOVES_PHASE1` from the two boxes and
+ the console unless Paul says to keep it.
+
+---
+
+## Customer instances (Runs 6 and 7)
+
+The runbook names no tenant. The real instances, their owners and the
+agreed windows live in the flagship's deployment repo and in the amadan
+thread where Paul gives each go.
+
+### Run 6: a disposable customer-shaped instance
+
+**Blockers:** P-0; **the home-region parks fix, both parts, merged and
+rolled** (customer instances live in the home region); a dev rehearsal
+from home passed once the fix is in; Run 5 passed; the owner's agreement;
+Paul's go.
+
+A disposable instance of a customer app with the same shape as moveprobe
+(a key file, a store, a sealed secret), created for the run with the
+owner's agreement, before the flagship's claim went on (P-5).
+
+- [ ] Instance used: recorded in the deployment repo.
+- [ ] Run 1's steps only (a commit), without the moveprobe-specific
+ [report] checks. The owner checks the instance serves after the open.
+- [ ] R-5 and R-6 as written; [CLI] M-C7, exit 0.
+
+### Run 7: the first production customer instance
+
+**Blockers:** everything Run 6 needs; Run 6 passed; Paul's approval of the
+instance; a window agreed with its owner.
+
+The first production customer instance Paul approves, in a window agreed
+with its owner.
+
+- [ ] The instance was created before the flagship's claim went on, so it
+ is adopted at `instances/<host>` (P-5). One created later cannot move.
+- [ ] Run 6's steps. The cut window is the default 120 s.
+- [ ] Report `offline_s` back to the owner: [detail] `last.offline_s`, or
+ `offline_s` in `log/done.json`'s fields.
+
+---
+
+## After every run
+
+- [ ] Attach to the amadan thread: the `done` or `rolled_back` log entry
+ (its timings), the [report] files from before and after, the bucket
+ listings, and the host used.
+- [ ] A failed check stops the series. Fix its cause and repeat the run on
+ a fresh host before going on.
+
+---
+
+## Sources
+
+Paths are from the repo root. Line numbers are main's (`bc25674a`, the
+base of this branch); `cmd/moveprobe` is this branch's and not on main
+yet. Spec is `docs/superpowers/specs/2026-10-02-move-any-app-design.md`;
+copy is `docs/superpowers/plans/2026-10-06-moves-phase1-copy.md`.
+
+**`carlos move` (the CLI)**
+
+| what the runbook says | where |
+|---|---|
+| verbs `status`, `cut`, `abort`, `resolve`; flags `--host`, `--app`, `--account`, `--console` | `move.go:136-149` |
+| `--to`, `--manual-cut`, `--no-wait` on a request; `--wait` on status; `--retry`, `--declare-lost`, `--no-wait` on resolve | `move.go:151-163` |
+| exit codes 0, 1, 2, 3, 4, 5 | `move.go:40-45`, `:53-61` |
+| "carlos move needs --app" | `move.go:66`, `:230` |
+| "Moves are not enabled on this console." | `move.go:65`, `:240-243` |
+| refuses to run as root; declare-lost reads EC2 with your own credentials | `move.go:211-217`, `:80`, `:900-903` |
+| the request sends `region` and `cut` | `move.go:248-253` |
+| M-C2, M-C3, M-C4 | `move.go:703`, `:707`, `:717` |
+| M-C12 at ready with a manual cut, exit 4 | `move.go:722-726` |
+| M-C10, exit 4 | `move.go:740-754` |
+| M-C7 on done, M-C9 on rolled_back, exit 3 | `move.go:788-803` |
+| M-C18 and the typed host name | `move.go:860-882` |
+| "No move is in flight" | `move.go:595` |
+| M-C1, M-C5, M-C6, M-C7, M-C8, M-C9 texts | `internal/moves/operatorcopy.go:11-38`; copy record rows M-C1 to M-C22, `copy:24-45` |
+| the detail path, `/api/cli/apps/<acct>/<app>/instances/<host>/move?detail=1` | `move.go:230-232`, `:351-356`; `apimode.go:460-461` |
+| login tokens in `~/.carlos/credentials`, keyed by `console` | `auth.go:113-120`, `:139-148` |
+
+**The detail answer**
+
+| what | where |
+|---|---|
+| detail=1 only for operators, only with the switch on | `internal/console/apiinstancemove.go:312-327` |
+| flat fields `state`, `target`, `id`, `last`, `protocol`, `phase` | `internal/console/apiinstancemove.go:160-170` |
+| `detail.move`, `claim`, `target_report`, `source_report`, `evidence`, `log`, `timings`, `from`, `region` | `internal/console/apiinstancemove.go:1290-1318` |
+| `evidence.source` and `evidence.target` come from the record in flight | `internal/console/apiinstancemove.go:1367-1370`, `:1416-1423` |
+| `last` fields `id`, `outcome`, `at`, `offline_s` | `internal/provision/provision.go:1059-1064` |
+| `move.incarnation` | `internal/provision/provision.go:961` |
+| `rollback.dirty`, `target_phase`, `evidence`, `loss_ref`, `lost_at` | `internal/provision/provision.go:1067-1088` |
+| `landing.installed_db_sha256` | `internal/provision/provision.go:1052` |
+| `target_report.checks` T1 to T12, `attempts_admitted`, `cut_budget_s` | `internal/moves/readiness.go:18-31`, `:58-67` |
+| `source_report.s1`, `s2`; S1 refuses a nested instance | `internal/moves/readiness.go:102-109`; `internal/host/movesphase1.go:41`, `:537` |
+
+**The log and the buckets**
+
+| what | where |
+|---|---|
+| identities and events: `requested`, `cutting-requested`/`cut_requested`, `cutting-flipped`/`flipped`, `committed-opened`/`opened`, `committed-source_parked`/`source_parked`, `rolling_back`, `rolling_back-target-<phase>`, `rolling_back-target-lost`/`declared_lost`, `rolling_back-source-<phase>`, `stuck-<epoch>`, the closes; the state as the event for `ready` and `committed` | `internal/moves/transition.go:122-253` |
+| `retry-<epoch>` | `internal/host/movesevents.go:109` |
+| the fields every entry carries | `internal/moves/transition.go:42-71` |
+| the timing keys | `internal/moves/log.go:126-133` |
+| target phases `releasing`, `released`, `returning`, `returned`, `cleaning`, `cleaned`, `lost`; source phases up to `recovered`, `opened` | `internal/provision/provision.go:1148-1172` |
+| released goes to returning only when dirty, else to cleaning | `internal/host/movesrollback.go:566-575` |
+| report prefix `moves/<acct>/<app>/<host>/<op>/`, `source.json`, `target.json`, `stopped-<role>.json`, `log/<identity>.json` | `internal/moves/staging.go:39-50` |
+| the staging lane `moves/<host>/<op>/` in the target's data bucket | `internal/moves/staging.go:6` |
+| the record's account is the sqid | `internal/console/apiinstances.go:1240` |
+| the replica under `<CARLOS_HIBERNATE_PREFIX>/<host>` | `internal/activator/activator.go:119`, `:144-146`; `internal/host/movespass.go:471` |
+| parked objects at `parked/<inc>/<host>/<retirement>/`, a `manifest.json`, the source versions deleted | `internal/park/remote.go:230-232`, `:473`, `:497` |
+| stopped report `steps.tree`, `steps.stray`, `stray` (omitted when empty), digests | `internal/moves/evidence.go:11-20`, `:38-53` |
+| a clean rollback: `src_covered` and unchanged digests; the "clean ..., src_covered ..." line; `dirty` and `evidence` written at released | `internal/host/movesrollback.go:394-398`, `:521-526` |
+
+**The door and the fleet**
+
+| what | where |
+|---|---|
+| no moves into home, and its sentence | `internal/console/apiinstancemove.go:537-540` |
+| a region with no staging bucket is refused | `internal/console/apiinstancemove.go:580-583`; staging from `data_bucket`, `internal/console/instancesplacement.go:366` |
+| M-C21, more than one box | `internal/console/apiinstancemove.go:595-600` |
+| MV-N5, below the floor | `internal/console/apiinstancemove.go:613-616`; `copy:55` |
+| a box below the protocol, or not armed for the floor, or with an unjudged `carlos.prev`, is below the floor | `internal/console/boxselect.go:100-108` |
+| MV-N167 for a holder at `instances/<host>/<inc>` | `internal/console/apiinstancemove.go:143`, `:647-651`; `copy:217` |
+| a created holder is at `instances/<host>/<inc>`, an adopted one at `instances/<host>` | `internal/hostclaim/create.go:75`; `internal/hostclaim/adopt.go:20` |
+| the source must be an active, enrolled roster entry with no termination fence | `internal/console/apiinstancemove.go:657-684`, `:1267-1273` |
+| eligibility: fresh, enrolled, `region-ha-1`, the console's secrets fingerprint, not draining | `internal/boxchoice/boxchoice.go:21`, `:109-148` |
+| `region-ha-1` and the identity record only with `CARLOS_REGION_HA` | `internal/host/edgesfile.go:156-164`; `internal/host/boxrecord.go:25-28`, `:158-166` |
+| `CARLOS_REGION_HA` reads only ParseBool true values, so `on` is off | `internal/host/boxrecord.go:21-28` |
+| identity records at `control/boxes/<box>.json` | `internal/boxid/record.go:21`, `:28` |
+| `CARLOS_AWS_ACCOUNT` and `CONSOLE_HOME_REGION` on the console | `cmd/console/main.go:318`, `:321` |
+| the claim at `control/hosts/<host>.json`; `holder.location`, `obligations`, `quiet_after` | `internal/hostclaim/claim.go:56`, `:166`, `:171`, `:182` |
+| T12 reads `quiet_after`; H is 15 min plus 1 min skew | `internal/host/movesreport.go:756-762`; `internal/moves/lease.go:13`, `:22`; spec:2573 |
+| the console's claim recovery pass adopts with the claim on | `internal/console/hostclaimbackfill.go:40-48` |
+| the roster at `control/host-actors.json`; `boxes`, `state`, `enrollment`, `loss_ids`; a declaration quarantines | `internal/hostactors/roster.go:21`, `:30`, `:38-40`, `:72`, `:265-279` |
+| a quarantined box is refused admission | `internal/hostquarantine/quarantine.go:17-25` |
+| `CARLOS_HOST_CLAIM` is any non-empty value | `cmd/console/main.go:1364-1366`; `hostclaim_cli.go:22` |
+| `carlos ops host` exists only with `CARLOS_HOST_CLAIM` in the shell; `--readmit`, `--console`, the `readmit` prompt | `hostclaim_cli.go:35-39`, `:61-75`, `:143` |
+| readmit refuses a fenced box and a private report older than 15 min | `internal/hostresolve/readmit.go:18-40` |
+
+**The floor, the switch and the boxes**
+
+| what | where |
+|---|---|
+| the floor key and shape, `{"min","min_release"}` | `internal/moves/floor.go:20`, `:61-64` |
+| `vX.Y.Z` only; anything else fails closed | `internal/moves/floor.go:85-99` |
+| no code writes the floor; only reads | `internal/moves/floor.go:66-82` |
+| a box's floor only rises; lowering it is a manual step per box | `internal/host/movesfloor.go:31-36` |
+| `moves_protocol`, `moves_floor_min_release`, `prev_release`, `prev_allowed_for`, `data_bucket`, `secrets_key_fp` in the edges file at `control/edges/<box>.json` | `internal/host/edgesfile.go:218`, `:270`, `:295-304`; `internal/console/instancesplacement.go:22`, `:83-86` |
+| `control/moves-console.json` and its `secrets_key_fp` | `internal/moves/floor.go:25-33` |
+| the switch, `on`/`1`/`true`, default off | `internal/moves/switch.go:6-23` |
+| the copy gate keeps the default off while a row is pending | `approved_moves_copy_test.go:811` |
+| the default cut window, 120 s | `internal/moves/budget.go:17` |
+| `CARLOS_LAYOUT_DEFAULT=flat` | `internal/host/layoutleg.go:68-70`; `system.go:714` |
+| `carlos system status`, `carlos system layout status` | `system.go:75-76`, `:611-641`; `README.md:272` |
+| the agent is `carlos-edge.service`, reading `/etc/carlos/host.env`, with a delegated cgroup | `internal/host/units/carlos-edge.service:18-19`, `:101` |
+| "exec-child accounting armed"; no manager, no stop evidence | `internal/cgroup/cgroup.go:181-184`; `internal/host/movespass.go:376-392` |
+| "not proven stopped yet ... retrying until the cut's deadline" | `internal/host/movescut.go:422-427` |
+| M-C20 and the page outbox `<DataDir>/.moves/.page-outbox/<host>/<move>/<rule>.json`, rule `stray` | `internal/host/movespaging.go:47-50`, `:87`, `:546-554`, `:586-588` |
+| the tree step's "could not be stopped" | `internal/stopevidence/reconstruct_linux.go:185` |
+| alert sinks `CARLOS_ALERT_WEBHOOK`, `CARLOS_ALERT_SNS_TOPIC_ARN`; the console arms them only with the claim and the switch on | `internal/alerting/alerting.go:191-209`; `cmd/console/main.go:1352-1362` |
+| `CARLOS_SECRETS_KEY` on the box | `internal/host/movespass.go:311-316` |
+| `/data` is the data directory | `internal/ops/ops.go:27` |
+
+**Timers and moveprobe**
+
+| what | where |
+|---|---|
+| a held timer row is skipped while the instance moves | `internal/schedule/runner.go:190-197` |
+| a periodic schedule's next fire is the next boundary after now (no replay) | `internal/schedule/spec.go:119-125` |
+| moveprobe's region is `home` with no `CARLOS_REGION` | `cmd/moveprobe/main.go:94-100` |
+| `bg` and `worker` rows each numbered from 1 in one sequence | `cmd/moveprobe/main.go:543-550`, `:158`, `:674` |
+| `/report`'s fields, `/arm-once`'s answer, the failure switch's modes | `cmd/moveprobe/README.md`, "Use it", "Make readiness fail on purpose" and "Strings" |
+
+**Other CLI commands**
+
+| what | where |
+|---|---|
+| `carlos env set`, `unset`, `list` with `--app` and `--account` | `appconfig_cli.go:342-350`, `:408-417`, `:462-467`; `cli.go:2045-2051` |
+| `carlos instances create --host --region --account` | `instances_cli.go:297-310` |
+
+**Not on main, cited by name**
+
+| what | where |
+|---|---|
+| the cut guard and T13 | branch `moves-cut-guard`, commits `083a6eaa` and `6871d2e3` |
+| the home parks fix parts 1 and 2 | branch `moves-home-parks` (no commits yet); #236 |
+| claim Plan 12; moves Plan 5 Tasks 9b and 8, Plan 4 Tasks 9 and 10 | plans only; the landing, rollback and recovery are unarmed until then, `internal/host/movespass.go:335-340` |
+| dev's second region | platform-infrastructure#42 |
+
+**Spec**
+
+| what | where |
+|---|---|
+| the runs and the pass rule | spec §14.4, spec:6263-6276 |
+| the rollout order, the floor before any protected production rehearsal | spec §15, spec:6278-6294 |
+| the flagship's home is Ireland | spec:1032 |