carlos / platform Public

Merge remote-tracking branch 'origin/main' into feat/hostclaim-moves-integration

# Conflicts:
#	docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
claude agent, operated by paul@keymail.dev 321900166fd2038e1d8d3e6804f2c31c92b5d475 parent 5459757parent b04ff85
17 files changed, +744 −22
  • approved_moves_copy_test.go +1 −0
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +11 −0
  • 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

CI passed — run details

CI log
=== gate ===
go: downloading github.com/aws/aws-sdk-go-v2 v1.44.0
go: downloading github.com/aws/aws-sdk-go-v2/service/ec2 v1.324.0
go: downloading github.com/aws/aws-sdk-go-v2/config v1.32.6
go: downloading github.com/aws/aws-sdk-go-v2/service/acm v1.45.0
go: downloading github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0
go: downloading github.com/aws/aws-sdk-go-v2/service/iam v1.58.2
go: downloading github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.37.0
go: downloading github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1
go: downloading github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
go: downloading github.com/aws/aws-sdk-go-v2/service/sts v1.45.6
go: downloading github.com/aws/smithy-go v1.28.1
go: downloading github.com/aws/aws-sdk-go-v2/service/sns v1.43.0
go: downloading github.com/benbjohnson/litestream v0.5.14
go: downloading modernc.org/sqlite v1.55.0
go: downloading github.com/superfly/ltx v0.5.1
go: downloading golang.org/x/sys v0.46.0
go: downloading golang.org/x/crypto v0.52.0
go: downloading golang.org/x/net v0.55.0
go: downloading github.com/oschwald/maxminddb-golang v1.13.1
go: downloading github.com/aws/aws-sdk-go-v2/credentials v1.19.6
go: downloading github.com/klauspost/compress v1.18.0
go: downloading github.com/aws/aws-sdk-go-v2/service/sesv2 v1.66.6
go: downloading github.com/pion/logging v0.2.4
go: downloading github.com/pion/stun/v3 v3.0.1
go: downloading github.com/pion/turn/v4 v4.1.4
go: downloading github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5
go: downloading github.com/pierrec/lz4/v4 v4.1.22
go: downloading github.com/prometheus/client_golang v1.17.0
go: downloading golang.org/x/sync v0.21.0
go: downloading github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18
go: downloading github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40
go: downloading github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.40
go: downloading github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16
go: downloading github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4
go: downloading github.com/aws/aws-sdk-go-v2/service/signin v1.0.4
go: downloading github.com/aws/aws-sdk-go-v2/service/sso v1.30.8
go: downloading github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12
go: downloading github.com/lmittmann/tint v1.1.3
go: downloading github.com/mattn/go-isatty v0.0.20
go: downloading github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31
go: downloading github.com/pion/dtls/v3 v3.0.7
go: downloading github.com/pion/transport/v3 v3.0.8
go: downloading github.com/pion/randutil v0.1.0
go: downloading github.com/pion/transport/v4 v4.0.1
go: downloading github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40
go: downloading github.com/cespare/xxhash/v2 v2.3.0
go: downloading github.com/prometheus/client_model v0.5.0
go: downloading github.com/beorn7/perks v1.0.1
go: downloading github.com/prometheus/common v0.45.0
go: downloading github.com/prometheus/procfs v0.12.0
go: downloading google.golang.org/protobuf v1.36.10
go: downloading github.com/wlynxg/anet v0.0.5
go: downloading github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0
go: downloading golang.org/x/text v0.37.0
go: downloading modernc.org/libc v1.74.1
go: downloading github.com/google/uuid v1.6.0
go: downloading github.com/dustin/go-humanize v1.0.1
go: downloading modernc.org/mathutil v1.7.1
go: downloading modernc.org/memory v1.11.0
go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	318.809s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.970s
ok  	github.com/carlosframework/platform/internal/activator	205.412s
ok  	github.com/carlosframework/platform/internal/agentsock	0.016s
ok  	github.com/carlosframework/platform/internal/alerting	0.007s
ok  	github.com/carlosframework/platform/internal/analytics	0.020s
ok  	github.com/carlosframework/platform/internal/appconfig	2.280s
ok  	github.com/carlosframework/platform/internal/apperr	0.078s
ok  	github.com/carlosframework/platform/internal/applog	0.136s
ok  	github.com/carlosframework/platform/internal/awsinv	0.010s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.630s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.008s
ok  	github.com/carlosframework/platform/internal/caps	0.012s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.184s
ok  	github.com/carlosframework/platform/internal/changelog	0.287s
ok  	github.com/carlosframework/platform/internal/clamd	0.055s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	357.363s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	6.108s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.012s
ok  	github.com/carlosframework/platform/internal/dbverify	0.005s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.009s
ok  	github.com/carlosframework/platform/internal/docsite	0.009s
ok  	github.com/carlosframework/platform/internal/domains	1.987s
ok  	github.com/carlosframework/platform/internal/doorkey	0.009s
ok  	github.com/carlosframework/platform/internal/economics	0.009s
ok  	github.com/carlosframework/platform/internal/edge	25.641s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.009s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.127s
ok  	github.com/carlosframework/platform/internal/gate	1.656s
ok  	github.com/carlosframework/platform/internal/genselect	0.018s
ok  	github.com/carlosframework/platform/internal/geo	0.011s
ok  	github.com/carlosframework/platform/internal/geoip	0.035s
ok  	github.com/carlosframework/platform/internal/host	51.675s
ok  	github.com/carlosframework/platform/internal/hostactors	0.556s
ok  	github.com/carlosframework/platform/internal/hostbox	1.350s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.261s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.008s
ok  	github.com/carlosframework/platform/internal/hostexec	0.302s
ok  	github.com/carlosframework/platform/internal/hostfence	0.052s
ok  	github.com/carlosframework/platform/internal/hostfiles	4.155s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.016s
ok  	github.com/carlosframework/platform/internal/hostguard	0.147s
ok  	github.com/carlosframework/platform/internal/hostops	1.321s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.009s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.002s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.136s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.002s
ok  	github.com/carlosframework/platform/internal/instancectl	1.701s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.684s
ok  	github.com/carlosframework/platform/internal/instancefs	0.004s
ok  	github.com/carlosframework/platform/internal/instancekey	0.002s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.006s
ok  	github.com/carlosframework/platform/internal/layout	1.332s
ok  	github.com/carlosframework/platform/internal/lease	9.429s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.006s
ok  	github.com/carlosframework/platform/internal/ledger	0.023s
ok  	github.com/carlosframework/platform/internal/mail	0.153s
ok  	github.com/carlosframework/platform/internal/maintenance	0.084s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.513s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.071s
ok  	github.com/carlosframework/platform/internal/metrics	0.008s
ok  	github.com/carlosframework/platform/internal/moves	21.031s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.739s
ok  	github.com/carlosframework/platform/internal/objstore	0.162s
ok  	github.com/carlosframework/platform/internal/ops	0.153s
ok  	github.com/carlosframework/platform/internal/park	0.178s
ok  	github.com/carlosframework/platform/internal/privatefile	0.003s
ok  	github.com/carlosframework/platform/internal/provision	3.153s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.006s
ok  	github.com/carlosframework/platform/internal/registry	3.671s
ok  	github.com/carlosframework/platform/internal/relay	7.213s
ok  	github.com/carlosframework/platform/internal/release	19.077s
ok  	github.com/carlosframework/platform/internal/rollout	1.481s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.006s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.003s
ok  	github.com/carlosframework/platform/internal/schedule	2.220s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	2.311s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.129s
ok  	github.com/carlosframework/platform/internal/status	0.036s
ok  	github.com/carlosframework/platform/internal/steering	0.005s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.125s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.002s
ok  	github.com/carlosframework/platform/internal/updatewords	0.002s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.001s
ok  	github.com/carlosframework/platform/internal/vet	1.541s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
go: downloading github.com/ncruces/go-strftime v1.0.0
--- shard 1 of 5 ---
cross-compiling darwin/amd64
go: downloading github.com/ncruces/go-strftime v1.0.0
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
go: downloading github.com/ncruces/go-strftime v1.0.0
--- shard 4 of 5 ---
cross-compiling windows/arm64
go: downloading github.com/ncruces/go-strftime v1.0.0
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
  minio: downloaded, digest verified
  mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
  mc: downloaded, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.022s
=== RUN   TestT4StorageProbeMinio
2026/10/12 05:00:59 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DDAF8FFC69E1AA, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.015s
minio contract: PASS
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index bd3e0482e..eef047380 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -85,6 +85,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
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 eb8277a65..a3b6ad52e 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -405,3 +405,14 @@ The 58 rows that were still pending on 2026-10-11 (MV-N187, MV-N313 to MV-N337,
| MV-N391 | internal/provision/provision.go | target fenced | approved |
MV-N391 was approved by Paul on 2026-10-12 with no edits, with claim Plan 12's two box-log strings.
+
+## 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 b373f2e85..3251bc138 100644
--- a/internal/host/movescut.go
+++ b/internal/host/movescut.go
@@ -217,6 +217,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
@@ -237,8 +240,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
@@ -258,31 +261,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 a94566d51..0d8226ce0 100644
--- a/internal/host/movescut_test.go
+++ b/internal/host/movescut_test.go
@@ -140,6 +140,9 @@ func newCutHarness(t *testing.T) *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 0aea92faf..75eb0a29c 100644
--- a/internal/host/movesphase1_harness_test.go
+++ b/internal/host/movesphase1_harness_test.go
@@ -533,6 +533,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 37fb725c9..b8f4ca651 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -708,8 +708,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 c40e65e48..b224cf4de 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)
+}