carlos / platform Public

Merge remote-tracking branch 'origin/main' into moves-home-parks

claude agent, operated by paul@keymail.dev ee3fa7095f5ca0fbca692af4d2c563d351854c26 parent 295cf03parent b04ff85
17 files changed, +807 −81
  • approved_moves_copy_test.go +4 −1
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +71 −58
  • docs/superpowers/specs/2026-10-02-move-any-app-design.md +1 −0
  • internal/host/movescut.go +70 −10
  • internal/host/movescut_test.go +3 −0
  • internal/host/movescutguard_test.go +461 −0
  • internal/host/movesphase1.go +10 −0
  • internal/host/movesphase1_harness_test.go +3 −0
  • internal/host/movesrecover_test.go +7 −2
  • internal/host/movesreport.go +17 −1
  • internal/moves/readiness.go +20 −2
  • internal/moves/readiness_test.go +40 −3
  • internal/provision/move_test.go +35 −0
  • internal/provision/plan9_b8_census_test.go +2 −2
  • internal/provision/provision.go +26 −2
  • move.go +13 −0
  • move_test.go +24 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index ebaf2c325..5144c05eb 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -84,6 +84,7 @@ var movesCopyIDs = []string{
"MV-N372", "MV-N373", "MV-N374", "MV-N375", "MV-N376", "MV-N377", "MV-N378", "MV-N379",
"MV-N380", "MV-N381",
"MV-N385", "MV-N386", "MV-N387", "MV-N389", "MV-N390",
+ "MV-N440", "MV-N441", "MV-N442", "MV-N443",
}
// copyRow is one row of the record. The File cell is the file the sentence is
@@ -1874,7 +1875,9 @@ func TestMovesGateBitesOnTheRealRecord(t *testing.T) {
t.Errorf("an approved row whose text is in its file: problems = %q", got)
}
- if got := checkPendingKeepsSwitchOff(rows, true); len(got) == 0 {
+ // The real record may have no pending rows left once Paul approves them
+ // all, so the switch check bites on the record plus one pending row.
+ if got := checkPendingKeepsSwitchOff(extra, true); len(got) == 0 {
t.Error("a default that is on beside pending rows must fail")
}
}
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
index 16921bf60..d3274cfd2 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -248,7 +248,7 @@ Columns: id, file, text, status.
| MV-N184 | internal/moves/startcheck.go | this box holds another generation of the instance | approved |
| MV-N185 | internal/moves/startcheck.go | the instance's files belong to another generation | approved |
| MV-N186 | internal/moves/startcheck.go | the instance's database belongs to another generation | approved |
-| MV-N187 | internal/provision/provision.go | the cut recorded no probe attempts for this landing | pending |
+| MV-N187 | internal/provision/provision.go | the cut recorded no probe attempts for this landing | approved |
## Plan 4
@@ -276,36 +276,36 @@ Columns: id, file, text, status.
| id | file | text | status |
|---|---|---|---|
-| MV-N313 | internal/host/movesrollback.go | carlos move stuck: <host> | pending |
-| MV-N314 | internal/host/movesrollback.go | The move <id> of <host> stopped for an operator on <box>, its <role>: <reason>. Run carlos move resolve --host <host> --retry once the cause is fixed. | pending |
+| MV-N313 | internal/host/movesrollback.go | carlos move stuck: <host> | approved |
+| MV-N314 | internal/host/movesrollback.go | The move <id> of <host> stopped for an operator on <box>, its <role>: <reason>. Run carlos move resolve --host <host> --retry once the cause is fixed. | approved |
Task 7's pages, the console's move pass (`internal/console/movesrecovery.go`, §13). MV-N319 to MV-N324 are Plan 6 Task 2's.
| id | file | text | status |
|---|---|---|---|
-| MV-N325 | internal/console/movesrecovery.go | carlos move waiting for <to> to release: <host> | pending |
-| MV-N326 | internal/console/movesrecovery.go | The move <id> of <host> is rolling back, and <to> has not released it by its deadline. <host> stays offline until a box in <to> confirms it has stopped, or that box is declared lost. | pending |
-| MV-N327 | internal/console/movesrecovery.go | carlos move preparing for over an hour: <host> | pending |
-| MV-N328 | internal/console/movesrecovery.go | The move <id> of <host> to <to> has been preparing for over an hour. It aborts itself six hours after the request if <to> is still not ready. | pending |
-| MV-N329 | internal/console/movesrecovery.go | carlos move without a record: <host> | pending |
-| MV-N330 | internal/console/movesrecovery.go | The host claim of <host> is held by the move <id>, and the instance record carries no move. Nothing releases it on its own. An operator must check the record. | pending |
-| MV-N331 | internal/console/movesrecovery.go | carlos move close overdue: <host> | pending |
-| MV-N332 | internal/console/movesrecovery.go | The move <id> of <host> was ready to close for more than 10 minutes and no box closed it. The console has closed it. | pending |
-| MV-N333 | internal/console/movesrecovery.go | carlos move abort not closed: <host> | pending |
-| MV-N334 | internal/console/movesrecovery.go | The abort of the move <id> of <host> has nothing left to park in <to>, and <box> has not closed it. <box> closes it once it has removed the move's snapshots from its own bucket. Check <box>. | pending |
+| MV-N325 | internal/console/movesrecovery.go | carlos move waiting for <to> to release: <host> | approved |
+| MV-N326 | internal/console/movesrecovery.go | The move <id> of <host> is rolling back, and <to> has not released it by its deadline. <host> stays offline until a box in <to> confirms it has stopped, or that box is declared lost. | approved |
+| MV-N327 | internal/console/movesrecovery.go | carlos move preparing for over an hour: <host> | approved |
+| MV-N328 | internal/console/movesrecovery.go | The move <id> of <host> to <to> has been preparing for over an hour. It aborts itself six hours after the request if <to> is still not ready. | approved |
+| MV-N329 | internal/console/movesrecovery.go | carlos move without a record: <host> | approved |
+| MV-N330 | internal/console/movesrecovery.go | The host claim of <host> is held by the move <id>, and the instance record carries no move. Nothing releases it on its own. An operator must check the record. | approved |
+| MV-N331 | internal/console/movesrecovery.go | carlos move close overdue: <host> | approved |
+| MV-N332 | internal/console/movesrecovery.go | The move <id> of <host> was ready to close for more than 10 minutes and no box closed it. The console has closed it. | approved |
+| MV-N333 | internal/console/movesrecovery.go | carlos move abort not closed: <host> | approved |
+| MV-N334 | internal/console/movesrecovery.go | The abort of the move <id> of <host> has nothing left to park in <to>, and <box> has not closed it. <box> closes it once it has removed the move's snapshots from its own bucket. Check <box>. | approved |
Task 9a's rows (the production park wiring). MV-N335 is the stuck reason a rollback records when the target's files cannot be archived at its release; it reaches `carlos move status` through Move.StuckReason and the stuck page (MV-N314).
| id | file | text | status |
|---|---|---|---|
-| MV-N335 | internal/provision/provision.go | the target's files cannot be archived now that it has stopped | pending |
+| MV-N335 | internal/provision/provision.go | the target's files cannot be archived now that it has stopped | approved |
The anomaly page (Task 9a's quiet refusals, `internal/host/movesrefusals.go`): a box pages once when the moves evidence for a host's at-rest cleanup contradicts itself. A warning; the reason stays in the agent's log.
| id | file | text | status |
|---|---|---|---|
-| MV-N336 | internal/host/movesrefusals.go | carlos move cleanup refused: <host> | pending |
-| MV-N337 | internal/host/movesrefusals.go | The cleanup of <host> on <box> stopped: its move history does not add up, or something wrote where no move could. Nothing of <host> is cleaned up in this region until an operator checks the agent's log on <box>. | pending |
+| MV-N336 | internal/host/movesrefusals.go | carlos move cleanup refused: <host> | approved |
+| MV-N337 | internal/host/movesrefusals.go | The cleanup of <host> on <box> stopped: its move history does not add up, or something wrote where no move could. Nothing of <host> is cleaned up in this region until an operator checks the agent's log on <box>. | approved |
## Floor gate fix (#267 review)
@@ -313,10 +313,10 @@ The anomaly page (Task 9a's quiet refusals, `internal/host/movesrefusals.go`): a
| id | file | text | status |
|---|---|---|---|
-| MV-N315 | internal/host/movesfloorgate.go | moves protocol floor: carlos.prev may not run: <error> | pending |
-| MV-N316 | internal/host/movesfloorgate.go | the verdict on carlos.prev does not name the floor on this box | pending |
-| MV-N317 | internal/host/movesfloorgate.go | <path> is not a regular file with one link, at most 256 bytes, owned by an expected account | pending |
-| MV-N318 | internal/host/movesfloorgate.go | floor-allows-prev takes two absolute paths: the floor file and the verdict file | pending |
+| MV-N315 | internal/host/movesfloorgate.go | moves protocol floor: carlos.prev may not run: <error> | approved |
+| MV-N316 | internal/host/movesfloorgate.go | the verdict on carlos.prev does not name the floor on this box | approved |
+| MV-N317 | internal/host/movesfloorgate.go | <path> is not a regular file with one link, at most 256 bytes, owned by an expected account | approved |
+| MV-N318 | internal/host/movesfloorgate.go | floor-allows-prev takes two absolute paths: the floor file and the verdict file | approved |
Plan 6 Task 7's audit: MV-N317 used to be said in two parts, the path from `movesfloorgate_linux.go` in front of a sentinel error in `movesfloorgate.go`. It is now one format in `movesfloorgate.go` (`floorGateUnsafe`), which still wraps the sentinel for `errors.Is` and prints the same text, so the gate can pass it once it is approved.
@@ -334,44 +334,44 @@ Completed by Plan 6 Task 7.
| id | file | text | status |
|---|---|---|---|
-| MV-N319 | internal/console/apiinstancemove.go | could not read the move's reports. try again | pending |
-| MV-N350 | move.go | Moves are not enabled on this console. | pending |
-| MV-N351 | move.go | carlos move needs --app: name the app the instance belongs to. | pending |
-| MV-N352 | move.go | Run carlos move as yourself, not as root. It uses your own credentials. | pending |
-| MV-N353 | move.go | carlos move works through a console. Unset CARLOS_DEPLOYMENT_BUCKET and CARLOS_DEPLOYMENT_DIR and run carlos auth login. | pending |
-| MV-N354 | move.go | This move's outcome is not recorded. | pending |
-| MV-N355 | move.go | The console is limiting requests. Checking every 10 seconds. | pending |
-| MV-N356 | move.go | carlos move needs --host <host>. | pending |
-| MV-N357 | move.go | carlos move needs --to <region>. | pending |
-| MV-N358 | move.go | carlos move resolve needs --retry or --declare-lost <box>. | pending |
-| MV-N359 | move.go | No move is in flight for <host>. | pending |
-| MV-N360 | move.go | Move <id> of <host> is <state>. | pending |
-| MV-N361 | move.go | The move was aborted. <host> stays in <from>. | pending |
-| MV-N362 | move.go | <host> is relocating after its box was terminated. This move will not finish. | pending |
-| MV-N363 | main.go | move an instance to another region: status | cut | abort | resolve --retry (operators; off unless the console enables it) | pending |
-| MV-N364 | move.go (M-C10's <box> when the claim names none) | the target box | pending |
-| MV-N365 | move.go | Could not reach the console. Check the connection and try again. | pending |
-| MV-N366 | move.go | The console's answer could not be read. | pending |
-| MV-N367 | move.go | Not found. Check --account and --app, and that you are a member of the instance's account. | pending |
-| MV-N368 | move.go | The console answered <status> and gave no reason. | pending |
-| MV-N369 | move.go | carlos move could not sign in to a console. Run carlos auth login, or check --console, CARLOS_CONSOLE and CARLOS_SERVICE_TOKEN. | pending |
-| MV-N370 | move.go | This console has moved to <url>. Run carlos auth login --console <url>, then run the command again. | pending |
-| MV-N371 | move.go | Stopped waiting. | pending |
-| MV-N372 | move.go | This records that <box> is gone with its disks. <host> keeps nothing there that it still needs. Type the host name to confirm. | pending |
-| MV-N373 | move.go | That is not <host>. Nothing was declared. | pending |
-| MV-N374 | move.go | declare-lost needs your AWS credentials to confirm the box is stopped or terminated. | pending |
-| MV-N375 | move.go | AWS does not show <box> stopped or terminated, so it cannot be declared lost. | pending |
-| MV-N376 | move.go | The move's flip time could not be read, so its target cannot be declared lost from here. | pending |
-| MV-N377 | move.go | The console asks to confirm a loss carlos move does not know. Use carlos ops host resolve. | pending |
-| MV-N378 | move.go | carlos move resolve takes --retry or --declare-lost, not both. | pending |
-| MV-N379 | move.go | The move changed since it was read. Nothing was declared. Run the command again. | pending |
-| MV-N380 | internal/moves/operatorcopy.go | <host> could not start in <to> or in <from>. It is offline. An operator has been paged. | pending |
-| MV-N381 | internal/moves/operatorcopy.go | the home region | pending |
-| MV-N385 | internal/host/movespaging.go | carlos move rolled back: <host> | pending |
-| MV-N386 | internal/host/movespaging.go | The move <id> of <host> rolled back, and <host> is serving on <box> again. | pending |
-| MV-N387 | internal/host/movespaging.go | carlos move stray process: <host> | pending |
-| MV-N389 | internal/host/movespaging.go | carlos move start refused: <host> | pending |
-| MV-N390 | internal/host/movespaging.go | <box> refused to start <host> for the move <id>: what it holds of <host> is not the generation the host claim names. Nothing was started. | pending |
+| MV-N319 | internal/console/apiinstancemove.go | could not read the move's reports. try again | approved |
+| MV-N350 | move.go | Moves are not enabled on this console. | approved |
+| MV-N351 | move.go | carlos move needs --app: name the app the instance belongs to. | approved |
+| MV-N352 | move.go | Run carlos move as yourself, not as root. It uses your own credentials. | approved |
+| MV-N353 | move.go | carlos move works through a console. Unset CARLOS_DEPLOYMENT_BUCKET and CARLOS_DEPLOYMENT_DIR and run carlos auth login. | approved |
+| MV-N354 | move.go | This move's outcome is not recorded. | approved |
+| MV-N355 | move.go | The console is limiting requests. Checking every 10 seconds. | approved |
+| MV-N356 | move.go | carlos move needs --host <host>. | approved |
+| MV-N357 | move.go | carlos move needs --to <region>. | approved |
+| MV-N358 | move.go | carlos move resolve needs --retry or --declare-lost <box>. | approved |
+| MV-N359 | move.go | No move is in flight for <host>. | approved |
+| MV-N360 | move.go | Move <id> of <host> is <state>. | approved |
+| MV-N361 | move.go | The move was aborted. <host> stays in <from>. | approved |
+| MV-N362 | move.go | <host> is relocating after its box was terminated. This move will not finish. | approved |
+| MV-N363 | main.go | move an instance to another region: status | cut | abort | resolve --retry (operators; off unless the console enables it) | approved |
+| MV-N364 | move.go (M-C10's <box> when the claim names none) | the target box | approved |
+| MV-N365 | move.go | Could not reach the console. Check the connection and try again. | approved |
+| MV-N366 | move.go | The console's answer could not be read. | approved |
+| MV-N367 | move.go | Not found. Check --account and --app, and that you are a member of the instance's account. | approved |
+| MV-N368 | move.go | The console answered <status> and gave no reason. | approved |
+| MV-N369 | move.go | carlos move could not sign in to a console. Run carlos auth login, or check --console, CARLOS_CONSOLE and CARLOS_SERVICE_TOKEN. | approved |
+| MV-N370 | move.go | This console has moved to <url>. Run carlos auth login --console <url>, then run the command again. | approved |
+| MV-N371 | move.go | Stopped waiting. | approved |
+| MV-N372 | move.go | This records that <box> is gone with its disks. <host> keeps nothing there that it still needs. Type the host name to confirm. | approved |
+| MV-N373 | move.go | That is not <host>. Nothing was declared. | approved |
+| MV-N374 | move.go | declare-lost needs your AWS credentials to confirm the box is stopped or terminated. | approved |
+| MV-N375 | move.go | AWS does not show <box> stopped or terminated, so it cannot be declared lost. | approved |
+| MV-N376 | move.go | The move's flip time could not be read, so its target cannot be declared lost from here. | approved |
+| MV-N377 | move.go | The console asks to confirm a loss carlos move does not know. Use carlos ops host resolve. | approved |
+| MV-N378 | move.go | carlos move resolve takes --retry or --declare-lost, not both. | approved |
+| MV-N379 | move.go | The move changed since it was read. Nothing was declared. Run the command again. | approved |
+| MV-N380 | internal/moves/operatorcopy.go | <host> could not start in <to> or in <from>. It is offline. An operator has been paged. | approved |
+| MV-N381 | internal/moves/operatorcopy.go | the home region | approved |
+| MV-N385 | internal/host/movespaging.go | carlos move rolled back: <host> | approved |
+| MV-N386 | internal/host/movespaging.go | The move <id> of <host> rolled back, and <host> is serving on <box> again. | approved |
+| MV-N387 | internal/host/movespaging.go | carlos move stray process: <host> | approved |
+| MV-N389 | internal/host/movespaging.go | carlos move start refused: <host> | approved |
+| MV-N390 | internal/host/movespaging.go | <box> refused to start <host> for the move <id>: what it holds of <host> is not the generation the host claim names. Nothing was started. | approved |
## Review of 2026-10-09
@@ -395,3 +395,16 @@ Built, but the gate could not read the row (3). Paul adopted three notation rule
- MV-N146: the note moved from the Text cell to the File cell (`internal/moves/readiness.go (the <which> of MV-N68)`), and the text is the fill-in `the disk's per-file sync`, which `readiness.go` says as one literal.
The 16 rows added after that review (MV-N184 to MV-N186, MV-N300 to MV-N312) were approved by Paul on 2026-10-09 with no edits.
+
+The 58 rows that were still pending on 2026-10-11 (MV-N187, MV-N313 to MV-N337, MV-N319, MV-N350 to MV-N381 and MV-N385 to MV-N390, without the withdrawn MV-N388) were approved by Paul on 2026-10-11 with no edits.
+
+## Moves cut guard
+
+Paul's ruling of 2026-10-11 (N1.3 in the Plan 7 preflight): the source refuses to start a cut unless the target's latest report states T13, that its box can land the move and roll it back, and unless the source can recover the move itself. MV-N440 is T13's reason, which `carlos move status` shows through M-C4 while the move is preparing. MV-N441 and MV-N442 are the refusals the cut records on the move (MoveBackToReadyRefused, like MV-N167). MV-N443 is the line `carlos move status` now prints for a ready move with a recorded refusal, MV-N167 included. MV-N444 to MV-N449 are not used.
+
+| id | file | text | status |
+|---|---|---|---|
+| MV-N440 | internal/host/movesreport.go | this box cannot land the move or roll it back yet | pending |
+| MV-N441 | internal/provision/provision.go | the target box cannot land the move or roll it back yet | pending |
+| MV-N442 | internal/provision/provision.go | the source box cannot recover the move if the cut fails | pending |
+| MV-N443 | move.go | The cut did not start: <reason>. | pending |
diff --git a/docs/superpowers/specs/2026-10-02-move-any-app-design.md b/docs/superpowers/specs/2026-10-02-move-any-app-design.md
index fc6d8c40e..6e2c2cc59 100644
--- a/docs/superpowers/specs/2026-10-02-move-any-app-design.md
+++ b/docs/superpowers/specs/2026-10-02-move-any-app-design.md
@@ -2571,6 +2571,7 @@ placement, the target writes `moves/<acct>/<app>/<host>/<move id>/target.json`:
| T9 claim | the claim is `moving` with this move id, its holder is the record's incarnation, and this region and box are in `touched` (added by CAS before the first staging write, claim spec §2) |
| T10 return lane | this box can put and delete `moves/_lane/<move id>` in the source's bucket (M17) |
| T12 quiet region | the claim's `quiet_after` for the target region (CC1) has passed, so the landing's park of an earlier stay never waits inside the cut window. A move back into a region the instance left within H waits at `ready`, not mid-cut. **Defence in depth (model, rev 5):** under claim rev 3 no state reaches it, because every retirement acknowledges only after its own horizon and CC11 refuses a move over a pending chore; the model drops T12 and no cut ever waits (§14.2.5). It stays, because it costs one comparison and guards any future path that acknowledges early |
+| T13 landing armed (amendment, 2026-10-11) | the target box reports that it can land the move and roll it back (`landingArmed` and `rollbackTargetArmed`); a report without the check counts as unarmed. The source also refuses to start the cut unless its own recovery is armed (`recoverySourceArmed`), checked at `requested` before the staging lease and again at `admitted` before the stop; a refusal returns the move to `ready` with the reason shown and is never stuck. Paul ruled it on 2026-10-11 so that the switch is not the only guard against a cut nothing can land. An old source ignores T13, so every box runs a release with it before moves are enabled |
| T11 budget | the **whole** stop-to-open estimate fits the operator's cut window (default 120 s) (codex R2-10, M11), from three measured rates, the **cross-region copy path**, **regional S3** (reads and writes from a box in the bucket's region) and the box's **disk**: stop + final checkpoint and `SyncAndWait` + final mirror (at most the 16 MiB bar at the cross-region rate) + archive build and upload + **the target's build (§7.6 `building`, R5-5, R6-3): the restore of the staged replica (the whole database read from regional S3 and written to disk), the snapshot write and the anchor write (each the whole database read from disk and written to regional S3)** + **the verification of the built database (§7.6 `restored`: one disk read for its sha256)** + render + **the probe attempts the window permits, each with its own settle** + the open (`open_budget`, 5 s) (joint N15, J2-N15). Rev 7.5 made the sealed transfer a snapshot of the stopped database and still priced the cross-region copy at the 16 MiB bar: a 2 GiB database over a 10 MiB/s path needs about 205 s for that copy alone, so T11 admitted a move the deadline then rolled back (R5-5). Rev 7.6 built the snapshot on the target and priced three passes at one local rate, then restored the generation cold a fourth time in `restored` without pricing it: the review's 3 GiB at 200 MiB/s with one probe came to 108.68 s priced and 124.04 s run (R6-3). Rev 7.7 verifies the built database instead of restoring it again and prices every byte moved. **Rev 7.11 prices the probe's own protocol (R10-8):** every probe attempt is a cold admission that purges the built database and **restores the prepared generation again** before the app starts (one regional S3 read and one disk write of the whole database), plus the admission's overhead (the CAS, the purge, `generation.json`: 2 s), the probe and its settle; every attempt after the first is a **fresh generation** whose first upload is the whole database again (one disk read and one regional S3 write). Rev 7.10 priced the previous protocol, three build passes, a verification and the probe alone, so the review's 2.5 GiB at the example rates was admitted at 113.8 s against the 120 s window and would have run to 131.2 s (129.2 s before the admission's overhead). **The worked example**, the same in this spec, the change note and `budget_test.go`: 2 GiB at 10 MiB/s across regions, 250 MiB/s to regional S3, 500 MiB/s on disk gives 53.6 s of fixed costs (restore 12.3, snapshot 12.3, anchor 12.3, verify 4.1, the rest 12.6), a first attempt of 64.3 s (restore 12.3, admission 2, probe 30, settle 20), 117.9 s in all, and admits **one** attempt by 2.1 s; a second attempt would add 76.6 s; the cross-region snapshot transfer (204.8 s) is refused by the same arithmetic, so is the review's 3 GiB workload at 200 MiB/s (rev 7.6's arithmetic admitted it), and so is 2.5 GiB. **The permitted attempts depend on the budget (R3-7):** the attempts admitted are those whose summed costs fit the window after the fixed costs, `attempt(1) = restore + admission + 30 s + 20 s`, `attempt(i > 1) = attempt(1) + full upload`, at most three, and the instance is eligible only if that is at least one; the probe loop (§7.7) runs at most that many. Rev 7.3 required all three attempts to fit, 150 s of probing against a 120 s default window, so no default move was ever eligible; `budget_test.go` (§14.2) states the arithmetic and shows a tiny instance admitted with two attempts, the boundary at 70 s of fixed costs, and never more than three. The estimate is recorded per phase as `cut_budget_s`, and admission sets an absolute `cut_deadline` (§7.4) |
**Source readiness.** S1: a probe request (§7.7) to the readiness path on
diff --git a/internal/host/movescut.go b/internal/host/movescut.go
index 9095c7284..c0d6f8c8b 100644
--- a/internal/host/movescut.go
+++ b/internal/host/movescut.go
@@ -218,6 +218,9 @@ func (c *cutRun) revert(ctx context.Context, reason string) (bool, error) {
// round rewrites.
func (c *cutRun) requested(ctx context.Context) (bool, error) {
cfg := c.cfg()
+ if refused, wrote, err := c.refuseUnarmed(ctx); refused || err != nil {
+ return wrote, err
+ }
sr, published, err := c.w.sourceRound(ctx, c.s, c.rec, c.fleet)
if err != nil || published == nil {
return false, err
@@ -238,8 +241,8 @@ func (c *cutRun) requested(ctx context.Context) (bool, error) {
}
return c.backToReady(ctx, "the inputs pinned at readiness have changed")
}
- if refused, err := c.refuseHolder(ctx); refused || err != nil {
- return refused, err
+ if refused, wrote, err := c.refuseHolder(ctx); refused || err != nil {
+ return wrote, err
}
now := cfg.Now()
budget := target.CutBudgetS
@@ -259,31 +262,88 @@ func (c *cutRun) admitted(ctx context.Context) (bool, error) {
if !same {
return c.backToReady(ctx, "the inputs pinned at readiness have changed")
}
- if refused, err := c.refuseHolder(ctx); refused || err != nil {
- return refused, err
+ if refused, wrote, err := c.refuseHolder(ctx); refused || err != nil {
+ return wrote, err
+ }
+ if refused, wrote, err := c.refuseUnarmed(ctx); refused || err != nil {
+ return wrote, err
}
return c.advance(ctx, provision.CutAdmitted, provision.CutStopping, nil)
}
+// refuseUnarmed is the cut guard (Paul's ruling, 2026-10-11): a cut starts
+// only when the target's latest report states that its box can land the
+// move and roll it back (T13, moves.TargetReport.Armed) and this box can
+// recover the move if the cut fails (recoverySourceArmed). Otherwise the
+// cut goes back to ready with the reason recorded, before this box takes
+// the staging lease, holds or stops anything, or seals the claim: it runs
+// first at requested and last at admitted, the step before stopping. It
+// fails closed: no report, or an older box's report with no T13, is
+// unarmed. The refusal stands while the cause does: decideReady pins no
+// move again while this box cannot recover, and the target's failing T13
+// keeps Ready from pinning it (§7.3), so nothing loops and the operator
+// reads why in `carlos move status`.
+//
+// refused and wrote are apart (fix round 1): refused is the guard's
+// verdict, wrote whether the refusal's record write landed. A refused cut
+// ends the step either way. When another writer moved the record first
+// (ErrMoveConflict, which cutTransition logs and answers false) nothing
+// was written, the pass ends, and the next pass reads the record again;
+// the cut never carries on to the source's round or to stopping.
+func (c *cutRun) refuseUnarmed(ctx context.Context) (refused, wrote bool, err error) {
+ reason := ""
+ if !recoverySourceArmed(c.cfg()) {
+ reason = provision.RefusedSourceUnarmed
+ } else {
+ target, err := readTargetReport(ctx, c.s, c.rec)
+ if err != nil {
+ return false, false, err
+ }
+ if !target.Armed() {
+ reason = provision.RefusedTargetUnarmed
+ }
+ }
+ if reason == "" {
+ return false, false, nil
+ }
+ log.Printf("moves: %s: cut %s refused: %s", c.rec.Host, c.g.ID, reason)
+ if err := c.fault("refusal"); err != nil {
+ return true, false, err
+ }
+ wrote, err = c.cutTransition(ctx, "the refusal", provision.MoveBackToReadyRefused(c.g, reason))
+ if wrote {
+ nudgeSerial(ctx, c.s)
+ }
+ return true, wrote, err
+}
+
// refuseHolder reads the claim fresh and, when its holder is not at the
// activator's lease location (holderSupported), sends the cut back to
// ready with RefusedReplicaLayout recorded (ruling T5-lease). The
// refusal stands as long as the claim does: a passing round pins again,
// the next cut is refused the same way, and the operator reads why.
-func (c *cutRun) refuseHolder(ctx context.Context) (bool, error) {
+//
+// refused and wrote are apart, as in refuseUnarmed (fix round 1): a
+// refused cut ends the step whether or not the refusal's record write
+// landed. A write another writer beat (ErrMoveConflict) writes nothing,
+// the pass ends, and the next pass reads the record again.
+func (c *cutRun) refuseHolder(ctx context.Context) (refused, wrote bool, err error) {
obs, err := c.claim(ctx)
if err != nil {
- return false, err
+ return false, false, err
}
if err := c.holderSupported(*obs.Claim.Holder); err != nil {
log.Printf("moves: %s: cut %s refused: %v", c.rec.Host, c.g.ID, err)
- ok, err := c.cutTransition(ctx, "the refusal", provision.MoveBackToReadyRefused(c.g, provision.RefusedReplicaLayout))
- if ok {
+ if err := c.fault("holder-refusal"); err != nil {
+ return true, false, err
+ }
+ wrote, err := c.cutTransition(ctx, "the refusal", provision.MoveBackToReadyRefused(c.g, provision.RefusedReplicaLayout))
+ if wrote {
nudgeSerial(ctx, c.s)
}
- return ok, err
+ return true, wrote, err
}
- return false, nil
+ return false, false, nil
}
// inputsHold compares the record's pin with the current inputs.
diff --git a/internal/host/movescut_test.go b/internal/host/movescut_test.go
index 8ce97ea5f..fb10f2292 100644
--- a/internal/host/movescut_test.go
+++ b/internal/host/movescut_test.go
@@ -140,6 +140,9 @@ func newCutHarness(t *testing.T, opts ...p1Option) *cutHarness {
}
return nil
}
+ // The cut guard: the source can recover a failed cut, as every box
+ // will once claim Plan 12 lands (armStandIns).
+ armStandIns(&h.cfg, h.s, roleSource)
return h
}
diff --git a/internal/host/movescutguard_test.go b/internal/host/movescutguard_test.go
new file mode 100644
index 000000000..85f59a381
--- /dev/null
+++ b/internal/host/movescutguard_test.go
@@ -0,0 +1,461 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The cut guard (Paul's ruling, 2026-10-11): the source refuses to start a
+// cut unless the target's latest report states that its box can land the
+// move and roll it back (T13), and unless the source itself can recover.
+// The refusal comes before anything stops, the writer's lease is taken or
+// the claim is sealed, and the move stays ready with the reason recorded.
+
+// errStandIn is what a harness stand-in answers. The stand-ins arm a box
+// so that the arming predicates hold, as they do once claim Plan 12 lands;
+// a test that reaches one has run a step its harness never wired.
+var errStandIn = errors.New("harness stand-in: this hook is not wired in this harness")
+
+func standIn(name string) error { return fmt.Errorf("%s: %w", name, errStandIn) }
+
+// armStandIns fills every hook landingArmed, rollbackTargetArmed and
+// recoverySourceArmed read that c lacks, with the park fakes (as
+// armParkFakes arms them, for role) and a stand-in for each other hook. A hook c already has is
+// kept, so a harness's own fakes win.
+func armStandIns(c *moveCfg, s release.Store, role string) {
+ if c.Guard.DataDir == "" {
+ c.Guard = hostguard.Guard{DataDir: c.DataDir}
+ }
+ if c.Local.DataDir == "" {
+ c.Local = park.Local{DataDir: c.DataDir, Box: c.Box}
+ }
+ if c.ParkLeases == nil {
+ c.ParkLeases = &fakeParkLeases{}
+ }
+ if c.ParkView == nil {
+ c.ParkView = recordView(s, c.Region, role)
+ }
+ if c.ParkAuthorize == nil {
+ c.ParkAuthorize = allowParkAuthorize
+ }
+ if c.ParkRunHeld == nil {
+ c.ParkRunHeld = testRunHeld
+ }
+ if c.ParkLocalAuthorize == nil {
+ c.ParkLocalAuthorize = allowLocalAuthorize
+ }
+ if c.OwnLease == nil {
+ c.OwnLease = func(context.Context, string, string) (park.HeldLease, error) { return nil, standIn("OwnLease") }
+ }
+ if c.RenderHeld == nil {
+ c.RenderHeld = func(context.Context, release.Store, registry.Route, string, func(string) error) (appconfig.HeldRender, error) {
+ return appconfig.HeldRender{}, standIn("RenderHeld")
+ }
+ }
+ if c.ReplaceTimers == nil {
+ c.ReplaceTimers = func(context.Context, string, []registry.Timer) error { return standIn("ReplaceTimers") }
+ }
+ if c.SetReplication == nil {
+ c.SetReplication = func(string, string) error { return standIn("SetReplication") }
+ }
+ if c.SetNextDue == nil {
+ c.SetNextDue = func(string, int64) error { return standIn("SetNextDue") }
+ }
+ if c.RemoveVerifyEntry == nil {
+ c.RemoveVerifyEntry = func(context.Context, release.Store, string) error { return standIn("RemoveVerifyEntry") }
+ }
+ if c.WriteGen == nil {
+ c.WriteGen = func(string, string) error { return standIn("WriteGen") }
+ }
+ if c.ReleaseReady == nil {
+ c.ReleaseReady = func(context.Context, release.Store, provision.Record, string) moves.Check {
+ return moves.Check{Reason: standIn("ReleaseReady").Error()}
+ }
+ }
+ if c.HandHold == nil {
+ c.HandHold = func(string, string, string, string, func() error) (bool, error) { return false, standIn("HandHold") }
+ }
+ if c.ReleaseHeld == nil {
+ c.ReleaseHeld = func(string, string) (bool, error) { return false, standIn("ReleaseHeld") }
+ }
+ if c.Timers == nil {
+ c.Timers = func(string) ([]registry.Timer, error) { return nil, standIn("Timers") }
+ }
+ if role != roleTarget {
+ return
+ }
+ if c.DeleteHeld == nil {
+ c.DeleteHeld = func(string, string) (bool, error) { return false, standIn("DeleteHeld") }
+ }
+ if c.Fence == nil {
+ c.Fence = func(context.Context, string, uint64) error { return standIn("Fence") }
+ }
+ if c.TimersDrained == nil {
+ c.TimersDrained = func(context.Context, string) error { return standIn("TimersDrained") }
+ }
+ if c.Evidence == nil {
+ c.Evidence = func(context.Context, string, string, []string) (moves.StoppedReport, error) {
+ return moves.StoppedReport{}, standIn("Evidence")
+ }
+ }
+ if c.FinalCheckpoint == nil {
+ c.FinalCheckpoint = func(context.Context, string, string, hostclaim.Holder, func(context.Context) error) error {
+ return standIn("FinalCheckpoint")
+ }
+ }
+ if c.LocalPosition == nil {
+ c.LocalPosition = func(string) (uint64, error) { return 0, standIn("LocalPosition") }
+ }
+}
+
+// unarmedTarget is the cut harness's target with the landing or the
+// rollback left unwired, as every box is in production until claim Plan 12.
+func (h *cutHarness) unarmedTarget(t *testing.T, unarm func(*moveCfg)) moveCfg {
+ t.Helper()
+ c := h.target()
+ if !landingArmed(c) || !rollbackTargetArmed(c) {
+ t.Fatal("fixture: the harness's target is not armed")
+ }
+ unarm(&c)
+ if landingArmed(c) && rollbackTargetArmed(c) {
+ t.Fatal("fixture: the target is still armed")
+ }
+ return c
+}
+
+// nothingStopped fails unless the move is ready again with reason recorded
+// and the pass did nothing irreversible: no hold, fence, drain, stop
+// evidence (which reads the writer's lease) or final checkpoint, no staging
+// lease taken and no status set since the counts before the pass, and the
+// claim still the moving op under the source with nothing sealed.
+func (h *cutHarness) nothingStopped(t *testing.T, reason string, acquired, statuses int) {
+ t.Helper()
+ m := h.move(t)
+ if m.State != provision.MoveReady || m.CutPhase != "" || m.Inputs != nil || m.CutAt != nil || m.Reason != reason {
+ t.Fatalf("not refused back to ready with %q: %+v", reason, m)
+ }
+ if h.ev.calls != 0 || len(h.holds) != 0 || h.fences != 0 || h.drains != 0 || h.checkpoints != 0 || len(h.locations) != 0 {
+ t.Fatalf("something stopped: evidence %d, holds %v, fences %d, drains %d, checkpoints %d, leases read %v",
+ h.ev.calls, h.holds, h.fences, h.drains, h.checkpoints, h.locations)
+ }
+ if got := h.lease.acquired; got != acquired {
+ t.Fatalf("the refusal took the staging lease: %d acquisitions, want %d", got, acquired)
+ }
+ if len(h.statuses) != statuses {
+ t.Fatalf("the source's row changed status: %v", h.statuses[statuses:])
+ }
+ c := h.claim(t)
+ if c.Op.Exec != "ie-box" || c.Op.Kind != hostclaim.KindMoving || c.Op.Phase != hostclaim.PhaseMoving {
+ t.Fatalf("the claim moved: %+v", c.Op)
+ }
+ if _, err := journalledSeal(c); err == nil {
+ t.Fatal("the claim carries a seal")
+ }
+}
+
+// The harnesses arm both sides, so the cut runs exactly as before: the
+// target's report states T13, the source's recovery is armed.
+func TestCutGuardBothArmedCutsAsBefore(t *testing.T) {
+ h := newCutHarness(t)
+ h.ready(t)
+ if r := h.targetReport(t); !r.Armed() || r.Checks[moves.CheckArmed] != (moves.Check{OK: true}) {
+ t.Fatalf("the armed target's report: %+v", r.Checks[moves.CheckArmed])
+ }
+ if !recoverySourceArmed(h.cfg) {
+ t.Fatal("fixture: the source's recovery is not armed")
+ }
+ h.cut(t)
+ h.pass(t)
+ if m := h.move(t); m.State != provision.MoveLanding || m.CutPhase != provision.CutFlipped || m.Reason != "" {
+ t.Fatalf("the armed cut did not flip: %+v", m)
+ }
+}
+
+// A target whose report says it cannot land or roll back is refused at
+// requested, before the source's round takes the staging lease: the move
+// is ready again, unpinned, with the reason, and nothing stopped. So is a
+// report from an older box, which has no T13 at all.
+func TestCutGuardRefusesAnUnarmedTarget(t *testing.T) {
+ ctx := context.Background()
+ for name, spoil := range map[string]func(t *testing.T, h *cutHarness){
+ "its landing is not armed": func(t *testing.T, h *cutHarness) {
+ h.targetPass(t, h.unarmedTarget(t, func(c *moveCfg) { c.OwnLease = nil }))
+ },
+ "its rollback is not armed": func(t *testing.T, h *cutHarness) {
+ h.targetPass(t, h.unarmedTarget(t, func(c *moveCfg) { c.DeleteHeld = nil }))
+ },
+ "an older box's report has no T13": func(t *testing.T, h *cutHarness) {
+ rec := h.read(t, p1Host)
+ key := moves.TargetReportKey(moves.ReportPrefix(rec.Account, rec.App, rec.Host, rec.Move.ID))
+ body, _, err := h.s.Get(ctx, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var raw map[string]json.RawMessage
+ if err := json.Unmarshal(body, &raw); err != nil {
+ t.Fatal(err)
+ }
+ var checks map[string]json.RawMessage
+ if err := json.Unmarshal(raw["checks"], &checks); err != nil {
+ t.Fatal(err)
+ }
+ delete(checks, moves.CheckArmed)
+ raw["checks"], _ = json.Marshal(checks)
+ body, _ = json.Marshal(raw)
+ if err := moves.PutReplace(ctx, h.s, key, body); err != nil {
+ t.Fatal(err)
+ }
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ h := newCutHarness(t)
+ h.ready(t)
+ spoil(t, h)
+ if h.targetReport(t).Armed() {
+ t.Fatal("fixture: the report still states T13")
+ }
+ h.cut(t)
+ acquired, statuses := h.lease.acquired, len(h.statuses)
+ h.pass(t)
+ h.nothingStopped(t, provision.RefusedTargetUnarmed, acquired, statuses)
+ })
+ }
+}
+
+// A source that cannot recover a failed cut refuses it the same way.
+func TestCutGuardRefusesAnUnarmedSource(t *testing.T) {
+ for name, unarm := range map[string]func(*moveCfg){
+ "no recovery open": func(c *moveCfg) { c.ReleaseHeld = nil },
+ "no hand-over": func(c *moveCfg) { c.HandHold = nil },
+ "no landing for return": func(c *moveCfg) { c.ParkRunHeld = nil },
+ } {
+ t.Run(name, func(t *testing.T) {
+ h := newCutHarness(t)
+ h.ready(t)
+ unarm(&h.cfg)
+ if recoverySourceArmed(h.cfg) {
+ t.Fatal("fixture: the source is still armed")
+ }
+ h.cut(t)
+ acquired, statuses := h.lease.acquired, len(h.statuses)
+ h.pass(t)
+ h.nothingStopped(t, provision.RefusedSourceUnarmed, acquired, statuses)
+ })
+ }
+}
+
+// At admitted (a record an older source admitted without the guard), the
+// guard refuses before the step to stopping. The target writes its report
+// while the move is ready, so the unarmed one is published before the cut.
+func TestCutGuardRefusesAtAdmitted(t *testing.T) {
+ ctx := context.Background()
+ h := newCutHarness(t)
+ h.ready(t)
+ h.targetPass(t, h.unarmedTarget(t, func(c *moveCfg) { c.SetNextDue = nil }))
+ h.cut(t)
+ g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc}
+ now := h.clock.Now()
+ if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120), 3)); err != nil {
+ t.Fatal(err)
+ }
+ acquired, statuses := h.lease.acquired, len(h.statuses)
+ h.pass(t)
+ h.nothingStopped(t, provision.RefusedTargetUnarmed, acquired, statuses)
+}
+
+// The refusal stands without a loop: an automatic cut refused because the
+// source cannot recover is not pinned again or cut again while that holds,
+// so later passes write nothing. Once the source is armed, the next passing
+// round pins again and the automatic cut proceeds.
+func TestCutGuardRefusalStandsUntilArmed(t *testing.T) {
+ h := newCutHarness(t)
+ h.start(t, "auto")
+ rootHolder(t, h.phase1Harness)
+ armed := h.cfg
+ h.cfg.ReleaseHeld = nil
+ h.ev.stopped = false // stop at stopping once armed, to look at it
+ h.pass(t)
+ h.targetPass(t, h.target())
+ for i := 0; i < 3; i++ {
+ h.pass(t)
+ }
+ refused := h.move(t)
+ if refused.State != provision.MoveReady || refused.Inputs != nil || refused.Reason != provision.RefusedSourceUnarmed {
+ t.Fatalf("the automatic cut was not refused: %+v", refused)
+ }
+ h.targetPass(t, h.target())
+ h.pass(t)
+ h.pass(t)
+ if m := h.move(t); m.UpdatedAt != refused.UpdatedAt || m.Inputs != nil || m.Reason != provision.RefusedSourceUnarmed {
+ t.Fatalf("passes after the refusal wrote the record: %+v", m)
+ }
+ if h.ev.calls != 0 || len(h.holds) != 0 {
+ t.Fatalf("something stopped: evidence %d, holds %v", h.ev.calls, h.holds)
+ }
+ h.cfg = armed
+ h.pass(t) // pins again and requests the cut
+ h.pass(t) // admits and starts stopping
+ if m := h.move(t); m.State != provision.MoveCutting || m.CutPhase != provision.CutStopping || m.Reason != "" {
+ t.Fatalf("the armed source did not cut: %+v", m)
+ }
+}
+
+// With the switch off, a requested cut on an unarmed source is left as main
+// leaves it: nothing refuses, nothing is written.
+func TestCutGuardSwitchOffIsMain(t *testing.T) {
+ h := newCutHarness(t)
+ h.ready(t)
+ h.cut(t)
+ before := h.move(t)
+ h.cfg.Enabled = false
+ h.cfg.ReleaseHeld = nil
+ h.pass(t)
+ h.pass(t)
+ if m := h.move(t); m.State != provision.MoveCutting || m.CutPhase != provision.CutRequested || m.Reason != "" || m.UpdatedAt != before.UpdatedAt {
+ t.Fatalf("the switch-off pass wrote the record: %+v", m)
+ }
+ if h.ev.calls != 0 || len(h.holds) != 0 {
+ t.Fatalf("something stopped: evidence %d, holds %v", h.ev.calls, h.holds)
+ }
+}
+
+// T13 gates readiness (§7.3): an unarmed target's report fails T13 with its
+// sentence, and the move stays preparing, so `carlos move status` shows why
+// before anyone asks to cut. Armed, it passes and the move is ready.
+func TestTargetReportStatesItsArming(t *testing.T) {
+ h := newCutHarness(t)
+ h.start(t, "manual")
+ rootHolder(t, h.phase1Harness)
+ h.pass(t)
+ h.targetPass(t, h.unarmedTarget(t, func(c *moveCfg) { c.ParkView = nil }))
+ r := h.targetReport(t)
+ if f := failing(r); len(f) != 1 || f[moves.CheckArmed] != t13NotArmed {
+ t.Fatalf("an unarmed target's failing checks: %v", f)
+ }
+ h.pass(t)
+ if m := h.move(t); m.State != provision.MovePreparing {
+ t.Fatalf("an unarmed target's move: %+v", m)
+ }
+ h.targetPass(t, h.target())
+ if f := failing(h.targetReport(t)); len(f) != 0 {
+ t.Fatalf("an armed target's failing checks: %v", f)
+ }
+ h.pass(t)
+ if m := h.move(t); m.State != provision.MoveReady || m.Inputs == nil {
+ t.Fatalf("an armed target's move: %+v", m)
+ }
+}
+
+// Fix round 1: a refusal whose record write loses to another writer still
+// ends the step. Here another writer takes the move back to ready just
+// before the refusal's write, so the refusal's CAS conflicts; the pass must
+// not carry on with the cut (the source's round and its staging lease at
+// requested, the step to stopping at admitted). The next pass re-reads.
+func TestCutGuardRefusalEndsTheStepOnAConflict(t *testing.T) {
+ ctx := context.Background()
+ for _, phase := range []string{provision.CutRequested, provision.CutAdmitted} {
+ t.Run(phase, func(t *testing.T) {
+ h := newCutHarness(t)
+ h.ready(t)
+ h.targetPass(t, h.unarmedTarget(t, func(c *moveCfg) { c.OwnLease = nil }))
+ h.cut(t)
+ g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc}
+ if phase == provision.CutAdmitted {
+ now := h.clock.Now()
+ if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120), 3)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ inner, conflicted := h.cfg.cutFault, false
+ h.cfg.cutFault = func(point string) error {
+ if point == "refusal" && !conflicted {
+ conflicted = true
+ if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveBackToReady(g)); err != nil {
+ t.Errorf("the conflicting write: %v", err)
+ }
+ }
+ return inner(point)
+ }
+ acquired, statuses := h.lease.acquired, len(h.statuses)
+ h.pass(t)
+ if !conflicted {
+ t.Fatal("the pass never reached the refusal")
+ }
+ // The other writer's decision stands: ready, unpinned, and
+ // no reason, since its write carried none.
+ h.nothingStopped(t, "", acquired, statuses)
+ })
+ }
+}
+
+// Fix round 1 (ruling on concern 2): the scoped-holder refusal (ruling
+// T5-lease) ends the step whatever its write returns, as the cut guard's
+// does. Another writer takes the move back to ready just before the
+// refusal's write, so its CAS conflicts; the pass ends there. At admitted
+// the step must not go on to the cut guard or to stopping (the target is
+// left unarmed so that a step that carried on would reach the guard's
+// "refusal" point); at requested it must not go on to the admission. The
+// staging lease the requested round takes before the holder check is
+// main's order (ruling T5-lease) and is not this refusal's.
+func TestCutHolderRefusalEndsTheStepOnAConflict(t *testing.T) {
+ ctx := context.Background()
+ for _, phase := range []string{provision.CutRequested, provision.CutAdmitted} {
+ t.Run(phase, func(t *testing.T) {
+ h := newCutHarness(t)
+ h.scoped = true
+ h.ready(t)
+ if phase == provision.CutAdmitted {
+ h.targetPass(t, h.unarmedTarget(t, func(c *moveCfg) { c.OwnLease = nil }))
+ }
+ h.cut(t)
+ g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc}
+ if phase == provision.CutAdmitted {
+ now := h.clock.Now()
+ if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120), 3)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ inner, conflicted := h.cfg.cutFault, false
+ var after []string
+ h.cfg.cutFault = func(point string) error {
+ if conflicted {
+ after = append(after, point)
+ }
+ if point == "holder-refusal" && !conflicted {
+ conflicted = true
+ if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveBackToReady(g)); err != nil {
+ t.Errorf("the conflicting write: %v", err)
+ }
+ }
+ return inner(point)
+ }
+ acquired, statuses := h.lease.acquired, len(h.statuses)
+ h.pass(t)
+ if !conflicted {
+ t.Fatal("the pass never reached the holder refusal")
+ }
+ if len(after) != 0 {
+ t.Fatalf("the step carried on after the conflicting refusal: %v", after)
+ }
+ if phase == provision.CutRequested {
+ acquired = h.lease.acquired // the round's own, before the holder check
+ }
+ h.nothingStopped(t, "", acquired, statuses)
+ if m := h.move(t); m.CutAt != nil || m.CutBudgetS != 0 {
+ t.Fatalf("the cut was admitted: %+v", m)
+ }
+ })
+ }
+}
diff --git a/internal/host/movesphase1.go b/internal/host/movesphase1.go
index 952c3a3c1..32d6ad17f 100644
--- a/internal/host/movesphase1.go
+++ b/internal/host/movesphase1.go
@@ -885,6 +885,16 @@ func (w *movesWorker) decideReady(ctx context.Context, s release.Store, rec prov
return nil
}
}
+ // The cut guard (Paul's ruling, 2026-10-11): a cut this box refused
+ // because it cannot recover the move (cutRun.refuseUnarmed) is not
+ // pinned again while that holds, so an automatic cut does not loop
+ // through the refusal. The first cut is still requested, and its
+ // refusal records the reason the operator reads. A target that cannot
+ // land fails T13, so Ready below keeps it from pinning.
+ if repin && !recoverySourceArmed(cfg) {
+ w.notice(rec.Host+"#unarmed", "moves: %s: move %s is not pinned again: %s", rec.Host, g.ID, provision.RefusedSourceUnarmed)
+ return nil
+ }
if state == provision.MovePreparing || repin {
target, err := readTargetReport(ctx, s, rec)
if err != nil {
diff --git a/internal/host/movesphase1_harness_test.go b/internal/host/movesphase1_harness_test.go
index 39d17362c..a4fc8aeee 100644
--- a/internal/host/movesphase1_harness_test.go
+++ b/internal/host/movesphase1_harness_test.go
@@ -549,6 +549,9 @@ func (h *phase1Harness) targetCfg() moveCfg {
now := h.clock.Now()
return moves.MeasuredRate{MiBPerS: 500, At: now, Bytes: 128 << 20}, moves.FilesRate{PerS: 1600, At: now, Files: 512}, nil
}
+ // The cut guard's T13: the target states it can land and roll back,
+ // as every box will once claim Plan 12 lands (armStandIns).
+ armStandIns(&c, h.s, roleTarget)
return c
}
diff --git a/internal/host/movesrecover_test.go b/internal/host/movesrecover_test.go
index b00ea422e..416c0ad40 100644
--- a/internal/host/movesrecover_test.go
+++ b/internal/host/movesrecover_test.go
@@ -298,8 +298,13 @@ func TestSourceWaitsForTargetEvidence(t *testing.T) {
t.Run("unarmed in production", func(t *testing.T) {
h := newRecoverHarness(t, false)
h.released(t)
- // h.cfg is the source box as production wires it: no park
- // engine leases (ruling G17), so the recovery waits for Plan 5.
+ // The source box as production wires it: no park engine leases
+ // (ruling G17), so the recovery waits for Plan 5. h.cfg was armed
+ // for the cut guard (armStandIns), so the leases are taken away.
+ h.cfg.ParkLeases = nil
+ if recoverySourceArmed(h.cfg) {
+ t.Fatal("fixture: the source's recovery is still armed")
+ }
h.pass(t)
untouched(t, h)
})
diff --git a/internal/host/movesreport.go b/internal/host/movesreport.go
index b1ffb51a3..b38e8a202 100644
--- a/internal/host/movesreport.go
+++ b/internal/host/movesreport.go
@@ -24,7 +24,8 @@ import (
"github.com/superfly/ltx"
)
-// The target's readiness report (moves spec §7.3, T1 to T12), behind
+// The target's readiness report (moves spec §7.3, T1 to T12, and the cut
+// guard's T13), behind
// CARLOS_MOVES_PHASE1. The named target box (the claim's op.intent.target)
// writes it every pass for every move into its placement that is preparing
// or ready; no other box writes one. Each check is its own small function
@@ -56,6 +57,7 @@ const (
t10Failed = "this box cannot write the return lane in the source's bucket"
t11NoReplica = "no replica has been staged yet"
t11Unmeasured = "the database size could not be measured" // MV-N69
+ t13NotArmed = "this box cannot land the move or roll it back yet"
)
func t1Below(min int) string {
@@ -244,6 +246,7 @@ func (w *movesWorker) targetReport(ctx context.Context, s release.Store, rec pro
rep.Checks[moves.CheckClaim] = w.checkClaim(ctx, obs, rec)
rep.Checks[moves.CheckLane] = w.checkLane(ctx, rec, fleet)
rep.Checks[moves.CheckQuiet] = checkQuiet(obs, rec.Move.To, now)
+ rep.Checks[moves.CheckArmed] = checkArmed(cfg)
// T11 from measured rates: this box's own, and the source's cross rate.
m := w.measure(ctx, rec)
@@ -274,6 +277,19 @@ func (w *movesWorker) targetReport(ctx context.Context, s release.Store, rec pro
return moves.PutReplace(ctx, s, moves.TargetReportKey(moves.ReportPrefix(rec.Account, rec.App, rec.Host, id)), body)
}
+// checkArmed is T13, the cut guard's arming statement (Paul's ruling,
+// 2026-10-11): this box can land the move and roll it back, every hook of
+// both wired, judged as the report is written. The source refuses to start
+// a cut unless the target's latest report states it (cutRun.refuseUnarmed).
+// In production both stay unarmed until claim Plan 12 lands. Its reason is
+// copy record MV-N440.
+func checkArmed(cfg moveCfg) moves.Check {
+ if landingArmed(cfg) && rollbackTargetArmed(cfg) {
+ return moves.Check{OK: true}
+ }
+ return moves.Check{Reason: t13NotArmed}
+}
+
// checkProtocol is T1 (§11): this box speaks at least the published floor
// and has armed it locally, so its executable selection enforces it. No
// published floor is a pass: the floor is opt-in until the first
diff --git a/internal/moves/readiness.go b/internal/moves/readiness.go
index 937f49c4b..a425e9e62 100644
--- a/internal/moves/readiness.go
+++ b/internal/moves/readiness.go
@@ -12,7 +12,8 @@ import (
"github.com/carlosframework/platform/internal/release"
)
-// The readiness checks of §7.3. The target box writes T1 to T12 into its
+// The readiness checks of §7.3. The target box writes T1 to T13 (T13 the cut
+// guard's, Paul's ruling of 2026-10-11) into its
// report; the source box writes S1 and S2 into its own. Ready joins them.
const (
CheckProtocol = "T1"
@@ -27,11 +28,17 @@ const (
CheckLane = "T10"
CheckBudget = "T11"
CheckQuiet = "T12"
+ // CheckArmed is the cut guard's T13 (Paul's ruling, 2026-10-11): the
+ // target box can land the move and roll it back, every hook of both
+ // wired, judged when the report is written. It is the report's arming
+ // statement: the source refuses to start a cut unless the target's
+ // latest report states it passing (TargetReport.Armed).
+ CheckArmed = "T13"
)
// AllChecks lists the target's checks in id order. Passing walks it, so the
// first failure it names is the lowest-numbered one.
-var AllChecks = []string{CheckProtocol, CheckSecrets, CheckEnv, CheckStorage, CheckArchive, CheckDisk, CheckRelease, CheckOneOwner, CheckClaim, CheckLane, CheckBudget, CheckQuiet}
+var AllChecks = []string{CheckProtocol, CheckSecrets, CheckEnv, CheckStorage, CheckArchive, CheckDisk, CheckRelease, CheckOneOwner, CheckClaim, CheckLane, CheckBudget, CheckQuiet, CheckArmed}
// ReportFreshness bounds how old a report may be and still decide ready
// (§7.3); the cut re-checks it at `admitted` (§7.4).
@@ -155,6 +162,17 @@ func (s *SourceReport) CrossMeasurement() MeasuredRate {
return MeasuredRate{MiBPerS: s.CrossRate, At: s.CrossRateAt}
}
+// Armed reports whether the report states that its box can land the move
+// and roll it back (CheckArmed). It fails closed: no report, a report from
+// an older box that has no T13, and a failing T13 are all unarmed.
+func (r *TargetReport) Armed() bool {
+ if r == nil {
+ return false
+ }
+ c, ok := r.Checks[CheckArmed]
+ return ok && c.OK
+}
+
// Passing reports whether every check passed, naming the first that did not
// in id order. A check that is missing from the report fails.
func (r TargetReport) Passing() (bool, string) {
diff --git a/internal/moves/readiness_test.go b/internal/moves/readiness_test.go
index 24edb851a..0db6d5b24 100644
--- a/internal/moves/readiness_test.go
+++ b/internal/moves/readiness_test.go
@@ -323,12 +323,12 @@ func TestPassingNamesTheFirstFailureInIDOrder(t *testing.T) {
}
}
-func TestAllChecksListsT1ToT12(t *testing.T) {
- want := []string{"T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8", "T9", "T10", "T11", "T12"}
+func TestAllChecksListsT1ToT13(t *testing.T) {
+ want := []string{"T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8", "T9", "T10", "T11", "T12", "T13"}
if !reflect.DeepEqual(AllChecks, want) {
t.Fatalf("%v", AllChecks)
}
- got := []string{CheckProtocol, CheckSecrets, CheckEnv, CheckStorage, CheckArchive, CheckDisk, CheckRelease, CheckOneOwner, CheckClaim, CheckLane, CheckBudget, CheckQuiet}
+ got := []string{CheckProtocol, CheckSecrets, CheckEnv, CheckStorage, CheckArchive, CheckDisk, CheckRelease, CheckOneOwner, CheckClaim, CheckLane, CheckBudget, CheckQuiet, CheckArmed}
if !reflect.DeepEqual(got, want) {
t.Fatalf("%v", got)
}
@@ -905,3 +905,40 @@ func TestReadyRefusesADatabaseGrownSinceTheTargetPriced(t *testing.T) {
}
}
}
+
+// The cut guard (Paul's ruling, 2026-10-11): T13 is the target's statement
+// that it can land the move and roll it back. Only a report that states it
+// passing is armed: a nil report, an older box's report that has no T13,
+// and a failing T13 all read unarmed, and an older report is not ready.
+func TestTargetReportArmedFailsClosed(t *testing.T) {
+ now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ var none *TargetReport
+ if none.Armed() {
+ t.Fatal("a missing report is armed")
+ }
+ r := passingTarget(now)
+ if !r.Armed() {
+ t.Fatal("a report with a passing T13 is not armed")
+ }
+ r.Checks[CheckArmed] = Check{Reason: "not armed"}
+ if r.Armed() {
+ t.Fatal("a failing T13 is armed")
+ }
+ old := passingTarget(now)
+ delete(old.Checks, CheckArmed)
+ if old.Armed() {
+ t.Fatal("an older box's report, with no T13, is armed")
+ }
+ if ok, why := Ready(0, 16<<20, old, goodSource(now), now); ok || why != "T13 was not reported" {
+ t.Fatalf("an older box's report: ready %v (%q)", ok, why)
+ }
+ // The wire form: an older box's JSON has no T13 and reads unarmed.
+ body, err := json.Marshal(old)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var back TargetReport
+ if err := json.Unmarshal(body, &back); err != nil || back.Armed() {
+ t.Fatalf("round trip of an older report: armed %v, %v", back.Armed(), err)
+ }
+}
diff --git a/internal/provision/move_test.go b/internal/provision/move_test.go
index 4f0ddd1ff..c748c4a49 100644
--- a/internal/provision/move_test.go
+++ b/internal/provision/move_test.go
@@ -584,6 +584,41 @@ func TestBackToReadyRecordsARefusal(t *testing.T) {
}
}
+// The cut guard (Paul's ruling, 2026-10-11): an unarmed target or an
+// unarmed source sends the cut back to ready from requested or admitted,
+// and each records its own sentence, not the replica layout's.
+func TestBackToReadyRecordsTheCutGuardRefusals(t *testing.T) {
+ g := cutTestGuard()
+ now := time.Now()
+ for _, reason := range []string{RefusedTargetUnarmed, RefusedSourceUnarmed} {
+ for _, phase := range []string{CutRequested, CutAdmitted} {
+ rec := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveCutting, CutPhase: phase, CutAt: &now, CutBudgetS: 120, CutDeadline: &now, Inputs: &MoveInputs{Release: "v1"}}}
+ if err := MoveBackToReadyRefused(g, reason)(rec); err != nil || rec.Move.State != MoveReady || rec.Move.CutPhase != "" || rec.Move.Inputs != nil || rec.Move.Reason != reason {
+ t.Fatalf("%s at %s: %v %+v", reason, phase, err, rec.Move)
+ }
+ }
+ }
+ if RefusedTargetUnarmed == RefusedSourceUnarmed || RefusedTargetUnarmed == RefusedReplicaLayout || RefusedSourceUnarmed == RefusedReplicaLayout {
+ t.Fatal("the refusals do not each have their own sentence")
+ }
+}
+
+// Fix round 1: a repin clears a recorded refusal. A move is pinned again
+// only once the refusal's cause is gone (decideReady and T13), so from then
+// on it is ready to cut and `carlos move status` no longer says the cut did
+// not start.
+func TestRepinClearsARecordedRefusal(t *testing.T) {
+ g := cutTestGuard()
+ now := time.Now()
+ rec := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveCutting, CutPhase: CutRequested, CutAt: &now, Inputs: &MoveInputs{Release: "v1"}}}
+ if err := MoveBackToReadyRefused(g, RefusedSourceUnarmed)(rec); err != nil || rec.Move.Reason != RefusedSourceUnarmed {
+ t.Fatalf("fixture: %v %+v", err, rec.Move)
+ }
+ if err := MoveGuardedRepin(g, MoveInputs{Release: "v2"})(rec); err != nil || rec.Move.Inputs == nil || rec.Move.Reason != "" {
+ t.Fatalf("the repin kept the refusal: %v %+v", err, rec.Move)
+ }
+}
+
// The landing marks a rollback it cannot yet start (Plan 2 Task 10): the
// reason is written on the landing record, the state and phase stay as they
// are, and Plan 4's rollback transition is the one call site that replaces
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 505b6d9b4..7d2573031 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -700,8 +700,8 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/host/movesphase1.go", "movesWorker.sourceRound", "nonrecord", "sel:PutIfAbsentVersioned", "c5bc2e6cb57b819ae1da485d37093c9a9ed3cdc9e9e16e8a8e312fd5ad2e2fe3"},
{"internal/host/movesphase1.go", "movesWorker.refreshCrossRate", "nonrecord", "sel:CopyVersioned", "7e03e680bd61be26dddcf168cacd2427bb535571dfd326c4a3130ebe65ea1968"},
{"internal/host/movesphase1.go", "movesWorker.writeSourceReport", "nonrecord", "sel:PutReplace", "370407cca2c1b91f6e5e7f135238f04c0dce9d7986e6e1ee9bebbf88cad0b9e0"},
- {"internal/host/movesphase1.go", "movesWorker.decideReady", "record-writer", "key:SetRecordMove", "927e1d864c3232ef17dfa7e9329ee26e870db75da128a8101234610a762e6f9a"},
- {"internal/host/movesreport.go", "movesWorker.targetReport", "nonrecord", "sel:PutReplace", "3f6aec125f1bd40271adf78833ac3268e536cbc5e8af3731b16f382669cd955b"},
+ {"internal/host/movesphase1.go", "movesWorker.decideReady", "record-writer", "key:SetRecordMove", "d29c22eafacfb894b96cd0c40ec447b6ab02f46ea087201685627861f992085d"},
+ {"internal/host/movesreport.go", "movesWorker.targetReport", "nonrecord", "sel:PutReplace", "fce9296957f2a8f91840ddeb5c3553a0a248bc11f7f98a08d0ae7d00ae1c2eb4"},
{"internal/host/movesstore.go", "laneProbe", "nonrecord", "sel:PutIfAbsent", "f4a17a1143e7370b9039b2e66e736606015c46619bab92c553ce7b1ad6b7918b"},
{"internal/host/snapshot_loop.go", "registrySnapshotPass", "nonrecord", "sel:Put", "6b01b46b361c3a2d1d432659a0d234c01975849b9a8ac5571858fdd78d376849"},
{"internal/host/snapshot_loop.go", "putReplacing", "nonrecord", "sel:Put", "11e8639effce142eab707b67cbdd425d0d7cea510ddf95d292bdba8f79c9b510"},
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index bd948a634..65a56288b 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -1362,6 +1362,10 @@ func MoveGuardedRepin(g MoveGuard, in MoveInputs) func(*Record) error {
return fmt.Errorf("%w: inputs are re-pinned only on a ready move that has none", ErrMoveConflict)
}
rec.Move.Inputs = pinInputs(in)
+ // A refusal MoveBackToReadyRefused recorded ends here (fix round
+ // 1): a move is pinned again only once its cause is gone, and from
+ // then on `carlos move status` says it is ready to cut.
+ rec.Move.Reason = ""
return nil
}
}
@@ -1478,6 +1482,17 @@ func MoveAdmit(g MoveGuard, now time.Time, budgetS int, deadline time.Time, atte
// recorded sentence (copy record MV-N167), like the Revert reasons.
const RefusedReplicaLayout = "the instance keeps its data under a per-incarnation path, which moves cannot cut yet"
+// The cut guard's refusals (Paul's ruling, 2026-10-11), recorded by
+// MoveBackToReadyRefused before anything stops. RefusedTargetUnarmed: the
+// target's latest report does not state that its box can land the move and
+// roll it back (moves.TargetReport.Armed; a report from an older box never
+// does). RefusedSourceUnarmed: the source box cannot run the recovery a
+// failed cut would need. Recorded sentences (copy record MV-N441, MV-N442).
+const (
+ RefusedTargetUnarmed = "the target box cannot land the move or roll it back yet"
+ RefusedSourceUnarmed = "the source box cannot recover the move if the cut fails"
+)
+
// MoveBackToReadyRefused is MoveBackToReady with the reason recorded on the
// move, for a refusal that stands until something outside the move
// changes (the operator reads it in `carlos move status`). reason must be
@@ -1485,14 +1500,23 @@ const RefusedReplicaLayout = "the instance keeps its data under a per-incarnatio
func MoveBackToReadyRefused(g MoveGuard, reason string) func(*Record) error {
return func(rec *Record) error {
switch reason {
- case RefusedReplicaLayout:
+ case RefusedReplicaLayout, RefusedTargetUnarmed, RefusedSourceUnarmed:
default:
return fmt.Errorf("provision: refusal reason %q has no recorded sentence", reason)
}
if err := MoveBackToReady(g)(rec); err != nil {
return err
}
- rec.Move.Reason = RefusedReplicaLayout
+ // Each reason is written as its constant, so the copy gate reads
+ // the recorded sentence (as MoveRevert writes its reasons).
+ switch reason {
+ case RefusedTargetUnarmed:
+ rec.Move.Reason = RefusedTargetUnarmed
+ case RefusedSourceUnarmed:
+ rec.Move.Reason = RefusedSourceUnarmed
+ default:
+ rec.Move.Reason = RefusedReplicaLayout
+ }
return nil
}
}
diff --git a/move.go b/move.go
index 670426511..6f4c67a7d 100644
--- a/move.go
+++ b/move.go
@@ -720,6 +720,19 @@ func (w *moveWatch) observe(a moveAnswer) int {
}
}
case "ready":
+ // A cut the source refused (the cut guard, a replica layout it
+ // cannot cut) is back at ready with the reason recorded, unpinned
+ // until the cause is gone and a repin clears the reason. Until then
+ // it is not ready to cut, so M-C12 is not said (fix round 1).
+ if j.Reason != "" {
+ if w.first("refused:" + j.Reason) {
+ moveSay(w.m.out, fmt.Sprintf("The cut did not start: %s.", j.Reason))
+ }
+ if j.Cut == "manual" {
+ return moveExitWaiting
+ }
+ break
+ }
if j.Cut == "manual" {
moveSay(w.m.out, fmt.Sprintf("Ready to cut. Run carlos move cut --host %s when you are ready.", host))
return moveExitWaiting
diff --git a/move_test.go b/move_test.go
index 18848be6b..e2f3a7390 100644
--- a/move_test.go
+++ b/move_test.go
@@ -15,6 +15,7 @@ import (
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostfence"
"github.com/carlosframework/platform/internal/hostresolve"
+ "github.com/carlosframework/platform/internal/provision"
"io"
"net/http"
"net/http/httptest"
@@ -1070,3 +1071,26 @@ func TestMoveDeclareLost(t *testing.T) {
wantLines(t, out, "declare-lost is not enabled in this deployment yet")
})
}
+
+// The cut guard (Paul's ruling, 2026-10-11): a cut the source refused goes
+// back to ready with the reason recorded on the move, and `carlos move
+// status` says it. While the refusal is recorded (until a repin clears it)
+// the move is not ready to cut, so M-C12 is not said (fix round 1). A ready
+// move with no reason says M-C12 alone.
+func TestMoveStatusSaysWhyTheCutDidNotStart(t *testing.T) {
+ for _, reason := range []string{provision.RefusedTargetUnarmed, provision.RefusedSourceUnarmed, provision.RefusedReplicaLayout} {
+ c := &fakeMoveConsole{frames: []frame{inFlight("ready", frame{"cut": "manual", "reason": reason}, nil)}}
+ code, out := runMoveAgainst(t, c, "status", "--host", tHost)
+ if code != moveExitWaiting {
+ t.Fatalf("exit %d:\n%s", code, out)
+ }
+ wantLines(t, out, "Move "+tMoveID+" of acme.go.tito.io is ready.", "The cut did not start: "+reason+".")
+ wantNoLine(t, out, mc12)
+ }
+ c := &fakeMoveConsole{frames: []frame{inFlight("ready", frame{"cut": "manual"}, nil)}}
+ code, out := runMoveAgainst(t, c, "status", "--host", tHost)
+ if code != moveExitWaiting || strings.Contains(out, "did not start") {
+ t.Fatalf("a ready move with no refusal = %d:\n%s", code, out)
+ }
+ wantLines(t, out, mc12)
+}