console: the move door admits a home target under host claims (home part 2)
Task 5. The Phase 1 request door lets a move name the home region, by its configured name, on a host-claim console (Ruling R1, R3): - without host claims a home target is refused with MV-N510 before any read or write; MV-N21 is withdrawn from the Phase 1 door; - the manifest allows a home target when an entry names the home (through homeRegionIn), or the list is empty; - movePlacements folds placementless edges files under "" for the move door only; placements() and armedPlacements() are unchanged, so create is not affected (TestCreatePlacementsUnchangedByHomeFold); - a box whose edges file names the home as its placement is refused with MV-N511, naming it, before the staging check (which would answer a misleading MV-N20); - MV-N2, MV-N5, MV-N16, MV-N20 (list via stagingNames), MV-N23, MV-N30 and MV-N177 name the home by its name; the record, the claim and BeginClaimMove still receive the code "" (claim intent.to = @home); - selectMoveBoxes reads the home name from moveHomeName. The legacy door (switch off) is untouched. Copy: MV-N510 and MV-N511 pending. Census: the two door units re-frozen at main's dispositions in the home-part2 overlay (ruling owed), the B8 row re-digested.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 files changed,
+603
−49
approved_moves_copy_test.go+2 −2docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+9 −1home_region_args_test.go+1 −1internal/console/apiinstancemove.go+94 −15internal/console/apiinstancemove_home_test.go+423 −0internal/console/boxselect.go+9 −7internal/console/instancesplacement.go+51 −20internal/provision/plan14_root_reads_test.go+6 −2internal/provision/plan9_b8_census_test.go+2 −1internal/provision/testdata/plan14-root-reads-home-part2.tsv+6 −0
CI passed — run details
CI log
=== gate ===
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 297.040s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.643s
ok github.com/carlosframework/platform/cmd/moveprobe 93.185s
ok github.com/carlosframework/platform/cmd/zddprobe 0.707s
ok github.com/carlosframework/platform/internal/activator 249.467s
ok github.com/carlosframework/platform/internal/agentsock 0.016s
ok github.com/carlosframework/platform/internal/alerting 0.004s
ok github.com/carlosframework/platform/internal/analytics 0.019s
ok github.com/carlosframework/platform/internal/appconfig 2.447s
ok github.com/carlosframework/platform/internal/apperr 0.075s
ok github.com/carlosframework/platform/internal/applog 0.127s
ok github.com/carlosframework/platform/internal/awsinv 0.003s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.646s
? 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.011s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.693s
ok github.com/carlosframework/platform/internal/changelog 0.309s
ok github.com/carlosframework/platform/internal/clamd 0.054s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 394.552s
? 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.171s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.010s
ok github.com/carlosframework/platform/internal/dbverify 0.008s
ok github.com/carlosframework/platform/internal/dnsrec 0.002s
ok github.com/carlosframework/platform/internal/docsite 0.009s
ok github.com/carlosframework/platform/internal/domains 2.337s
ok github.com/carlosframework/platform/internal/doorkey 0.009s
ok github.com/carlosframework/platform/internal/economics 0.008s
ok github.com/carlosframework/platform/internal/edge 26.726s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.007s
ok github.com/carlosframework/platform/internal/fleetapi 0.133s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.014s
ok github.com/carlosframework/platform/internal/geo 0.016s
ok github.com/carlosframework/platform/internal/geoip 0.033s
ok github.com/carlosframework/platform/internal/host 137.344s
ok github.com/carlosframework/platform/internal/hostactors 0.442s
ok github.com/carlosframework/platform/internal/hostbox 2.202s
ok github.com/carlosframework/platform/internal/hostclaim 0.489s
ok github.com/carlosframework/platform/internal/hostevidence 0.012s
ok github.com/carlosframework/platform/internal/hostexec 4.719s
ok github.com/carlosframework/platform/internal/hostfence 0.032s
ok github.com/carlosframework/platform/internal/hostfiles 10.449s
ok github.com/carlosframework/platform/internal/hostfloor 0.012s
ok github.com/carlosframework/platform/internal/hostguard 1.006s
ok github.com/carlosframework/platform/internal/hostops 1.556s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.012s
ok github.com/carlosframework/platform/internal/hostquarantine 0.012s
ok github.com/carlosframework/platform/internal/hostrelocate 0.436s
ok github.com/carlosframework/platform/internal/hostresolve 0.143s
ok github.com/carlosframework/platform/internal/hostwrite 0.012s
ok github.com/carlosframework/platform/internal/infraview 0.012s
ok github.com/carlosframework/platform/internal/instancectl 4.166s
ok github.com/carlosframework/platform/internal/instanceexec 2.709s
ok github.com/carlosframework/platform/internal/instancefs 0.016s
ok github.com/carlosframework/platform/internal/instancekey 0.014s
ok github.com/carlosframework/platform/internal/instanceupdates 0.020s
ok github.com/carlosframework/platform/internal/layout 1.492s
ok github.com/carlosframework/platform/internal/lease 9.791s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.017s
ok github.com/carlosframework/platform/internal/ledger 0.010s
ok github.com/carlosframework/platform/internal/mail 0.182s
ok github.com/carlosframework/platform/internal/maintenance 0.077s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.495s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.082s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 20.997s
ok github.com/carlosframework/platform/internal/moves/archive 0.723s
ok github.com/carlosframework/platform/internal/objstore 0.168s
ok github.com/carlosframework/platform/internal/ops 0.176s
ok github.com/carlosframework/platform/internal/park 0.205s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 13.238s
ok github.com/carlosframework/platform/internal/publicrelease 0.006s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.862s
ok github.com/carlosframework/platform/internal/relay 7.232s
ok github.com/carlosframework/platform/internal/release 19.601s
ok github.com/carlosframework/platform/internal/rollout 3.686s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.006s
ok github.com/carlosframework/platform/internal/rootparticipation 0.007s
ok github.com/carlosframework/platform/internal/rootstate 0.024s
ok github.com/carlosframework/platform/internal/runs 0.576s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.006s
ok github.com/carlosframework/platform/internal/schedule 2.465s
ok github.com/carlosframework/platform/internal/seeds 0.010s
ok github.com/carlosframework/platform/internal/sim/hostlife 8.705s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.817s
ok github.com/carlosframework/platform/internal/status 0.036s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.214s
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.003s
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.004s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.529s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 46399650 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, 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.03s)
--- 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.02s)
--- 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.035s
=== RUN TestT4StorageProbeMinio
2026/10/15 22:05:15 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DED3328C85E32B, 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.024s
minio contract: PASS
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index b237e0260..29f9b95d2 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -47,7 +47,7 @@ var movesCopyIDs = []string{ |
| "M-C18", "M-C19", "M-C20", "M-C21", "M-C22", "MV-N1", "MV-N2", "MV-N3", |
| "MV-N4", "MV-N5", "MV-N6", "MV-N7", "MV-N8", "MV-N9", "MV-N10", "MV-N11", |
| "MV-N12", "MV-N13", "MV-N14", "MV-N15", "MV-N16", "MV-N17", "MV-N18", |
| - "MV-N19", "MV-N20", "MV-N21", "MV-N22", "MV-N23", "MV-N24", "MV-N25", |
| + "MV-N19", "MV-N20", "MV-N22", "MV-N23", "MV-N24", "MV-N25", |
| "MV-N26", "MV-N27", "MV-N28", "MV-N29", "MV-N30", "MV-N31", "MV-N32", |
| "MV-N33", "MV-N34", "MV-N35", "MV-N36", "MV-N37", "MV-N38", "MV-N39", |
| "MV-N40", "MV-N41", "MV-N42", "MV-N43", "MV-N44", "MV-N45", "MV-N46", |
| @@ -89,7 +89,7 @@ var movesCopyIDs = []string{ |
| "MV-N440", "MV-N441", "MV-N442", "MV-N443", |
| "MV-N450", "MV-N451", "MV-N454", "MV-N455", "MV-N456", |
| "MV-N500", |
| - "MV-N512", "MV-N513", |
| + "MV-N510", "MV-N511", "MV-N512", "MV-N513", |
| } |
| // 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 50bc8524d..8712687aa 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -68,7 +68,6 @@ Columns: id, file, text, status. |
| | MV-N18 | internal/console/apiinstancemove.go | could not read this instance's storage binding. try again | approved | |
| | MV-N19 | internal/console/apiinstancemove.go | this deployment publishes no armed regions. nothing to move to | approved | |
| | MV-N20 | internal/console/apiinstancemove.go | region <to> cannot take a move in this deployment. it publishes no staging bucket. Regions that can: <list> | approved | |
| -| MV-N21 | internal/console/apiinstancemove.go | moves to the home region are not supported yet. the home publishes no staging lane | approved | |
| | MV-N22 | internal/console/apiinstancemove.go | could not read the moves protocol floor. try again | approved | |
| | MV-N23 | internal/console/apiinstancemove.go | no box in <to> publishes an edges file yet | approved | |
| | MV-N24 | internal/console/apiinstancemove.go | no eligible box in <from> publishes an edges file | approved | |
| @@ -462,3 +461,12 @@ Task 2b's row (fix round 3) is T3's refusal of a version 1 env report. A v1 repo |
| | id | file | text | status | |
| |---|---|---|---| |
| | MV-N513 | internal/host/movesreport.go | the source reported its launch environment in an older format | pending | |
| + |
| +Task 5's rows are the Phase 1 request door's two new refusals of a home target. MV-N510 is Ruling R1: only a host-claim console admits a move into home, so the plain path refuses it before any read or write. MV-N511 is a box that sets `CARLOS_REGION` to the home's name. The console reads that box as home but the box does not, so it cannot take the move; the door names it before the staging check, which would otherwise answer MV-N20 for a bucket the box may well publish. |
| + |
| +Task 5 withdraws MV-N21 ("moves to the home region are not supported yet. the home publishes no staging lane"). The Phase 1 door no longer speaks it: MV-N510 replaces it on the plain path, and a host-claim console admits the move. The legacy door (switch off) keeps main's em-dash sentence, which the gate exempts as main's own (`movesLegacyFuncs`) until Plan 6 Task 8 retires that door. MV-N2, MV-N5, MV-N20, MV-N23, MV-N30 and MV-N177 keep their approved text. Only their fills change: `<to>` (and MV-N20's `<list>`) now name the home by its configured name instead of the empty code. MV-N16 already did. |
| + |
| +| id | file | text | status | |
| +|---|---|---|---| |
| +| MV-N510 | internal/console/apiinstancemove.go | moves into the home region need host claims. this console runs without them | pending | |
| +| MV-N511 | internal/console/apiinstancemove.go | <box> sets CARLOS_REGION, so it cannot take a move into the home region | pending | |
diff --git a/home_region_args_test.go b/home_region_args_test.go| index 9a5e6ab6e..d901de411 100644 |
| --- a/home_region_args_test.go |
| +++ b/home_region_args_test.go |
| @@ -62,7 +62,7 @@ const ( |
| // callee. |
| var homeRegionCallSites = []homeRegionSite{ |
| {File: "internal/console/apiinstancemove.go", Func: "Server.handleAPIInstanceMoveRequest", Callee: "hostclaim.BeginMove(targetRegion)", Arg: "to", |
| - Witness: "named-only: the door refuses a home target before the plain path's BeginMove (MV-N21 today; MV-N510 under host claims, Task 5)"}, |
| + Witness: "named-only: without host claims the door refuses a home target before the plain path's BeginMove (MV-N510, Ruling R1; TestMoveDoorPlainPathRefusesHomeTarget); a host-claim console starts through BeginClaimMove"}, |
| {File: "internal/host/movesabort.go", Func: "movesWorker.abortClaim", Callee: "hostclaim.Abort(targetRegion)", Arg: "park.PlacementRegion(rec.Move.To)", Witness: wAbort}, |
| {File: "internal/host/movescut.go", Func: "movesWorker.revertClaim", Callee: "hostclaim.Revert(targetRegion)", Arg: "park.PlacementRegion(rec.Move.To)", Witness: wRevert}, |
| {File: "internal/host/movesinstall.go", Func: "installRun.parking", Callee: "hostclaim.QueueResidueParks(region)", Arg: "cfg.parkRegion()", Witness: wFresh}, |
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index 9f59bab8d..686bdb2f8 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -246,6 +246,59 @@ func stagingCodes(caps map[string]placementCapability) string { |
| return strings.Join(codes, ", ") |
| } |
| +// stagingNames is stagingCodes as the Phase 1 door says it (MV-N20): each |
| +// code under the name a caller passes (homeRegionOut), so movePlacements' |
| +// folded home is listed by its name. An unnamed home has no name a caller |
| +// could pass and is left out, and a home that is both folded and published |
| +// under its name is listed once. |
| +func (s *Server) stagingNames(caps map[string]placementCapability) string { |
| + names := make([]string, 0, len(caps)) |
| + for c, cap := range caps { |
| + if name := s.homeRegionOut(c); name != "" && cap.Reachable && cap.Staging { |
| + names = append(names, name) |
| + } |
| + } |
| + slices.Sort(names) |
| + names = slices.Compact(names) |
| + if len(names) == 0 { |
| + return "(none)" |
| + } |
| + return strings.Join(names, ", ") |
| +} |
| + |
| +// moveHomeName is the home region's configured name as the move door uses |
| +// it: the claim's home (dbClaimHomeRegion) on a host-claim console, |
| +// CONSOLE_HOME_REGION otherwise. It is the name homeRegionIn and |
| +// homeRegionOut translate with (they spell the rule out inline, which keeps |
| +// the copy gate's derivation of their sentences shallow), and box selection |
| +// reads the home with it, so the door and the selection agree when the |
| +// console's home is the claim-mode fallback. |
| +func (s *Server) moveHomeName() string { |
| + if s.cfg.HostClaim { |
| + return s.cfg.dbClaimHomeRegion() |
| + } |
| + return s.cfg.HomeRegion |
| +} |
| + |
| +// moveRegionAllowed is the manifest's allow-list for a move target. A |
| +// named target must be listed as it is. The home target ("") is allowed |
| +// when some entry names the home, read through homeRegionIn as the door |
| +// reads the caller's region. An empty list allows any target. |
| +func (s *Server) moveRegionAllowed(allowed []string, to string) bool { |
| + if len(allowed) == 0 { |
| + return true |
| + } |
| + if to != "" { |
| + return slices.Contains(allowed, to) |
| + } |
| + for _, r := range allowed { |
| + if s.homeRegionIn(r) == "" { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| // homeRegionIn/Out translate between the home region's NAME (Config's |
| // CONSOLE_HOME_REGION, what pickers and callers speak — issue #346) and the |
| // record's "" (the unconstrained value the fleet's reconcile has always |
| @@ -553,12 +606,22 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| jsonError(w, http.StatusBadRequest, "invalid region") |
| return |
| } |
| - if to == "" { |
| - jsonError(w, http.StatusBadRequest, "moves to the home region are not supported yet. the home publishes no staging lane") |
| + // Ruling R1 (home part 2): a move into the home region is admitted only |
| + // on a host-claim console, whose claim pins the home's engine region |
| + // (@home) under the roster's designation. The plain path refuses it |
| + // before any read or write. |
| + if to == "" && !s.cfg.HostClaim { |
| + jsonError(w, http.StatusBadRequest, "moves into the home region need host claims. this console runs without them") |
| return |
| } |
| + // toName is the target as the caller named it, which is |
| + // homeRegionOut(to): homeRegionIn maps only the home's own name to "", |
| + // so the round trip gives region back (Ruling R3: the caller names the |
| + // home by its configured name). Every sentence below names the home so, |
| + // never by the record's "". |
| + toName := region |
| if to == rec.Region { |
| - jsonError(w, http.StatusConflict, "the instance is already in "+s.homeRegionOut(to)) |
| + jsonError(w, http.StatusConflict, "the instance is already in "+toName) |
| return |
| } |
| // Both policy reads fail closed (round-4 finding 13): an unreadable |
| @@ -569,8 +632,8 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| jsonError(w, http.StatusServiceUnavailable, "could not read the app's instances manifest. try again") |
| return |
| } |
| - if m != nil && len(m.Regions) > 0 && !slices.Contains(m.Regions, to) { |
| - jsonError(w, http.StatusBadRequest, "region "+to+" is not one of this app's regions: "+strings.Join(m.Regions, ", ")) |
| + if m != nil && !s.moveRegionAllowed(m.Regions, to) { |
| + jsonError(w, http.StatusBadRequest, "region "+toName+" is not one of this app's regions: "+strings.Join(m.Regions, ", ")) |
| return |
| } |
| // Private storage is the instance's BINDING, not the app's create-time |
| @@ -591,13 +654,24 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| return |
| } |
| } |
| - caps, published := placements(ctx, s.cfg.Releases) |
| + caps, homeNamed, published := movePlacements(ctx, s.cfg.Releases, s.moveHomeName()) |
| + // Review Focus 5: a box that sets CARLOS_REGION to the home's name is |
| + // home to the console but not to itself, so it cannot take a move into |
| + // home. It is refused here, before the staging check: the home's fold |
| + // takes placementless files only, so that check would answer "publishes |
| + // no staging bucket", which hides what the operator has to fix. The |
| + // files are not liveness-joined, so a stale file of a box that has gone |
| + // refuses too (fail closed): the sentence names the file's box. |
| + if to == "" && len(homeNamed) > 0 { |
| + jsonError(w, http.StatusConflict, homeNamed[0]+" sets CARLOS_REGION, so it cannot take a move into the home region") |
| + return |
| + } |
| if !published { |
| jsonError(w, http.StatusBadRequest, "this deployment publishes no armed regions. nothing to move to") |
| return |
| } |
| if !caps[to].Reachable || !caps[to].Staging { |
| - jsonError(w, http.StatusBadRequest, "region "+to+" cannot take a move in this deployment. it publishes no staging bucket. Regions that can: "+stagingCodes(caps)) |
| + jsonError(w, http.StatusBadRequest, "region "+toName+" cannot take a move in this deployment. it publishes no staging bucket. Regions that can: "+s.stagingNames(caps)) |
| return |
| } |
| from := s.homeRegionOut(rec.Region) |
| @@ -618,7 +692,7 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| return |
| } |
| if sel.Target == "" { |
| - jsonError(w, http.StatusBadRequest, "no box in "+to+" publishes an edges file yet") |
| + jsonError(w, http.StatusBadRequest, "no box in "+toName+" publishes an edges file yet") |
| return |
| } |
| if sel.Source == "" { |
| @@ -630,7 +704,7 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| return |
| } |
| if !sel.FloorOK { |
| - jsonError(w, http.StatusConflict, "a box in "+from+" or "+to+" is below the moves protocol floor. roll the platform first") |
| + jsonError(w, http.StatusConflict, "a box in "+from+" or "+toName+" is below the moves protocol floor. roll the platform first") |
| return |
| } |
| inc, err := hostclaim.Incarnation(acct.Sqid, app, host, rec.CreatedAt) |
| @@ -727,7 +801,7 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| } |
| moving, err := hostclaim.Update(ctx, s.cfg.Releases, obs, hostclaim.BeginMove(id, addr, exec, sel.Target, to, now)) |
| if err != nil { |
| - moveClaimCASFailed(w, acct, app, host, to, err) |
| + moveClaimCASFailed(w, acct, app, host, toName, err) |
| return |
| } |
| got, err := provision.SetRecordMove(ctx, s.cfg.Releases, acct.Sqid, app, host, start) |
| @@ -744,12 +818,14 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| // moveClaimCASFailed answers a refused or failed start CAS, plain or |
| // admitted, with its recorded sentence. The error itself is only logged. |
| -func moveClaimCASFailed(w http.ResponseWriter, acct *Account, app, host, to string, err error) { |
| +// toName is the target as the caller named it (homeRegionOut), never the |
| +// record's "" for the home. |
| +func moveClaimCASFailed(w http.ResponseWriter, acct *Account, app, host, toName string, err error) { |
| switch { |
| case errors.Is(err, hostclaim.ErrRelocationFinishing): |
| jsonError(w, http.StatusConflict, "the instance's last relocation is still finishing. try again in a few minutes") |
| case errors.Is(err, hostclaim.ErrPendingChore): |
| - jsonError(w, http.StatusConflict, "the instance has unfinished cleanup in "+to+". wait for it to finish") |
| + jsonError(w, http.StatusConflict, "the instance has unfinished cleanup in "+toName+". wait for it to finish") |
| case errors.Is(err, hostclaim.ErrConflict), errors.Is(err, hostclaim.ErrWrongState): |
| jsonError(w, http.StatusConflict, "the host claim changed under this request. try again") |
| default: |
| @@ -781,9 +857,12 @@ func moveClaimCASFailed(w http.ResponseWriter, acct *Account, app, host, to stri |
| func (s *Server) startClaimModeMove(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, obs hostclaim.Observed, ms provision.MoveStart, target, from, recRegion string, dir hostactors.Directory, roster hostactors.Observed) { |
| ctx := r.Context() |
| a := s.cfg.ClaimAuthority |
| + // to is the record's code ("" for the home), which the claim calls |
| + // receive; toName is what the sentences say (home part 2). |
| to := ms.To |
| + toName := s.homeRegionOut(to) |
| if !roster.Roster.MatchesHome(s.cfg.dbClaimHomeRegion()) || !claimMoveRosterPlaces(roster.Roster, obs.Claim.Op.Exec, target, recRegion, to, s.cfg.dbClaimHomeRegion()) { |
| - jsonError(w, http.StatusConflict, "the host roster does not allow this move. the source and target must be active boxes in "+from+" and "+to) |
| + jsonError(w, http.StatusConflict, "the host roster does not allow this move. the source and target must be active boxes in "+from+" and "+toName) |
| return |
| } |
| fenced, err := dir.TerminationFence(ctx, roster, target, time.Now()) |
| @@ -793,7 +872,7 @@ func (s *Server) startClaimModeMove(w http.ResponseWriter, r *http.Request, acct |
| return |
| } |
| if fenced { |
| - jsonError(w, http.StatusConflict, "the host roster does not allow this move. the source and target must be active boxes in "+from+" and "+to) |
| + jsonError(w, http.StatusConflict, "the host roster does not allow this move. the source and target must be active boxes in "+from+" and "+toName) |
| return |
| } |
| appID, err := s.store.ClaimAppID(acct.ID, app) |
| @@ -828,7 +907,7 @@ func (s *Server) startClaimModeMove(w http.ResponseWriter, r *http.Request, acct |
| case errors.Is(err, hostops.ErrPending): |
| jsonError(w, http.StatusConflict, "another operation holds this app. try again when it finishes") |
| default: |
| - moveClaimCASFailed(w, acct, app, host, to, err) |
| + moveClaimCASFailed(w, acct, app, host, toName, err) |
| } |
| return |
| } |
diff --git a/internal/console/apiinstancemove_home_test.go b/internal/console/apiinstancemove_home_test.go| new file mode 100644 |
| index 000000000..13d57f031 |
| --- /dev/null |
| +++ b/internal/console/apiinstancemove_home_test.go |
| @@ -0,0 +1,423 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "reflect" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Moves into the home region, part 2, Task 5: the Phase 1 request door |
| +// admits a home target on a host-claim console (Ruling R1). The caller names |
| +// the home by its configured name (Ruling R3); the record and the claim keep |
| +// the code "" and the engine region @home. |
| + |
| +const homeMoveHost = "acme.go.tito.io" |
| + |
| +// caBox is a named region's box, the source of every move below. Its AWS |
| +// region is one the test signer has certificates for (boxidtest.Regions). |
| +func caBox(box string) fleetBox { |
| + return fleetBox{Box: box, Placement: "ca", AWS: "us-east-1", EIP: "1.2.3.5", Bucket: "data-ca"} |
| +} |
| + |
| +// homeFleetBox is a box of the home region as it publishes itself: it sets no |
| +// CARLOS_REGION, so its edges file carries no placement. Its roster entry |
| +// names the home (homeDoor sets it to "ie"). |
| +func homeFleetBox(box string) fleetBox { |
| + return fleetBox{Box: box, AWS: "eu-west-1", EIP: "1.2.3.3", Bucket: "data-home"} |
| +} |
| + |
| +// homeNamedBox is a box that sets CARLOS_REGION to the home's name. The |
| +// console reads it as home (homeRegionIn), the box does not. |
| +func homeNamedBox(box string) fleetBox { |
| + return fleetBox{Box: box, Placement: "ie", AWS: "eu-west-1", EIP: "1.2.3.6", Bucket: "data-ie"} |
| +} |
| + |
| +// homeDoorOpts shapes homeDoor's fixture. |
| +type homeDoorOpts struct { |
| + recRegion string // the create's region; "ca" when empty |
| + source string // the serving claim's box; "ca-box" when empty |
| + noDesignation bool // the roster names no home_region |
| + regions []string // the manifest's regions after the create (nil: none) |
| + chore string // a pending park chore's region on the claim ("": none) |
| + plain bool // no host claims: the plain Phase 1 path |
| +} |
| + |
| +// homeDoor is the Phase 1 door with the console's home named ie: the |
| +// instance created in ca (unless recRegion says otherwise) and claimed by |
| +// its source box, the fleet armed with boxes, the roster enrolling every |
| +// box (a placementless box under the home's name) and, unless |
| +// noDesignation, designating ie as home. Unless plain, the console runs |
| +// with host claims (armClaimMoves). It returns the move URL. |
| +func homeDoor(t *testing.T, code string, o homeDoorOpts, boxes ...fleetBox) (fx *consoleFixture, token, moveURL string) { |
| + t.Helper() |
| + ctx := context.Background() |
| + fx, token = operatorInstanceFixture(t, code) |
| + fx.srv.cfg.HomeRegion = "ie" |
| + m := titoManifest() |
| + m.Regions = nil |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + region, source := o.recRegion, o.source |
| + if region == "" { |
| + region = "ca" |
| + } |
| + if source == "" { |
| + source = "ca-box" |
| + } |
| + if c, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"`+homeMoveHost+`","region":"`+region+`"}`); c != http.StatusCreated { |
| + t.Fatalf("create = %d %s", c, out) |
| + } |
| + armMoveFleet(t, fx, boxes...) |
| + roster := hostactors.Roster{Boxes: map[string]hostactors.Box{}} |
| + for _, b := range boxes { |
| + rb := moveRosterBox(b) |
| + if b.Placement == "" { |
| + rb.Region = "ie" |
| + } |
| + roster.Boxes[b.Box] = rb |
| + } |
| + if !o.noDesignation { |
| + roster.HomeRegion = "ie" |
| + } |
| + putMoveRoster(t, fx, roster) |
| + claimFor(t, fx, homeMoveHost, source, region) |
| + if o.chore != "" { |
| + obs, err := hostclaim.Read(ctx, fx.rel, homeMoveHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := hostclaim.Update(ctx, fx.rel, obs, func(c *hostclaim.Claim) error { |
| + c.Obligations = append(c.Obligations, hostclaim.Obligation{ID: "chore-1", Op: "mv_earlier", Kind: "park-replica", |
| + Region: o.chore, Retirement: "ret-1", Owner: "region:" + o.chore}) |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if o.regions != nil { |
| + m.Regions = o.regions |
| + enableInstances(t, fx, "titogo", m) |
| + } |
| + if !o.plain { |
| + armClaimMoves(t, fx, true, true, nil) |
| + } |
| + return fx, token, u + "/" + homeMoveHost + "/move" |
| +} |
| + |
| +// homeMoveRegistrations is the number of registrations on titogo's |
| +// admission. |
| +func homeMoveRegistrations(t *testing.T, fx *consoleFixture) int { |
| + t.Helper() |
| + a, err := hostops.ReadAdmission(context.Background(), fx.rel, "bab", "titogo", claimMoveAppID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return len(a.Value.Registrations) |
| +} |
| + |
| +func homeMoveRecordBytes(t *testing.T, fx *consoleFixture) string { |
| + t.Helper() |
| + b, _, err := fx.rel.Get(context.Background(), provision.RecordKey("bab", "titogo", homeMoveHost)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return string(b) |
| +} |
| + |
| +// claimReadSpy counts reads of the host claims. |
| +type claimReadSpy struct { |
| + release.Store |
| + reads *atomic.Int32 |
| +} |
| + |
| +func (s claimReadSpy) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if strings.HasPrefix(key, "control/hosts/") { |
| + s.reads.Add(1) |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| + |
| +// A home target on a host-claim console is admitted: the caller names the |
| +// home (ie), the record journals the code "", the claim's op targets the |
| +// engine region @home under the roster's designation, and the view names |
| +// the home again. |
| +func TestMoveDoorClaimModeAcceptsHomeTarget(t *testing.T) { |
| + ctx := context.Background() |
| + fx, token, moveURL := homeDoor(t, "code-home-accept", homeDoorOpts{}, caBox("ca-box"), homeFleetBox("home-box")) |
| + code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie","cut":"manual"}`) |
| + if code != http.StatusAccepted { |
| + t.Fatalf("request = %d %s, want 202", code, out) |
| + } |
| + if !strings.Contains(out, `"target":"ie"`) { |
| + t.Fatalf("view = %s, want target ie", out) |
| + } |
| + obs, err := hostclaim.Read(ctx, fx.rel, homeMoveHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + op := obs.Claim.Op |
| + if op.Kind != hostclaim.KindMoving || op.Exec != "ca-box" || op.Intent["to"] != "@home" || op.Intent["home_region"] != "ie" || |
| + op.Intent["source"] != "ca-box" || op.Intent["target"] != "home-box" { |
| + t.Fatalf("claim op = %+v", op) |
| + } |
| + rec, err := provision.GetRecord(ctx, fx.rel, "bab", "titogo", homeMoveHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Move == nil || rec.Move.To != "" || rec.Move.From != "ca" || rec.Move.ID != op.ID { |
| + t.Fatalf("record move = %+v", rec.Move) |
| + } |
| +} |
| + |
| +// Ruling R1: without host claims the Phase 1 door refuses a home target |
| +// (MV-N510) before it writes anything. |
| +func TestMoveDoorPlainPathRefusesHomeTarget(t *testing.T) { |
| + fx, token, moveURL := homeDoor(t, "code-home-plain", homeDoorOpts{plain: true}, caBox("ca-box"), homeFleetBox("home-box")) |
| + claim, rec := claimBytes(t, fx, homeMoveHost), homeMoveRecordBytes(t, fx) |
| + code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`) |
| + if code != http.StatusBadRequest || errorText(out) != "moves into the home region need host claims. this console runs without them" { |
| + t.Fatalf("plain home target = %d %s, want MV-N510", code, out) |
| + } |
| + if claimBytes(t, fx, homeMoveHost) != claim || homeMoveRecordBytes(t, fx) != rec { |
| + t.Fatal("a refused home target wrote the claim or the record") |
| + } |
| +} |
| + |
| +// The home's staging comes from its own placementless edges file: without |
| +// a data bucket the door says so with MV-N20, naming the home and listing |
| +// no empty code; with one, it passes. |
| +func TestMoveDoorHomeStagingFromItsEdgesFile(t *testing.T) { |
| + bare := homeFleetBox("home-box") |
| + bare.Bucket = "" |
| + fx, token, moveURL := homeDoor(t, "code-home-staging", homeDoorOpts{}, caBox("ca-box"), bare) |
| + code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`) |
| + const want = "region ie cannot take a move in this deployment. it publishes no staging bucket. Regions that can: ca" |
| + if code != http.StatusBadRequest || errorText(out) != want { |
| + t.Fatalf("home without a data bucket = %d %s, want %q", code, out, want) |
| + } |
| + if homeMoveRegistrations(t, fx) != 0 { |
| + t.Fatal("a staging refusal registered a move") |
| + } |
| + |
| + fx, token, moveURL = homeDoor(t, "code-home-staging-ok", homeDoorOpts{}, caBox("ca-box"), homeFleetBox("home-box")) |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`); code != http.StatusAccepted { |
| + t.Fatalf("home with a data bucket = %d %s, want 202", code, out) |
| + } |
| + |
| + // The list names the home by its name, and never as an empty code. |
| + caps := map[string]placementCapability{ |
| + "": {Reachable: true, Staging: true}, |
| + "ca": {Reachable: true, Staging: true}, |
| + "uk": {Reachable: true}, |
| + } |
| + named := &Server{cfg: Config{HomeRegion: "ie"}} |
| + if got := named.stagingNames(caps); got != "ca, ie" { |
| + t.Fatalf("stagingNames with home ie = %q, want %q", got, "ca, ie") |
| + } |
| + unnamed := &Server{cfg: Config{}} |
| + if got := unnamed.stagingNames(caps); got != "ca" { |
| + t.Fatalf("stagingNames with no home named = %q, want %q", got, "ca") |
| + } |
| + if got := unnamed.stagingNames(map[string]placementCapability{"": {Reachable: true, Staging: true}}); got != "(none)" { |
| + t.Fatalf("stagingNames of an unnamed home alone = %q, want (none)", got) |
| + } |
| + both := map[string]placementCapability{"": {Reachable: true, Staging: true}, "ie": {Reachable: true, Staging: true}} |
| + if got := named.stagingNames(both); got != "ie" { |
| + t.Fatalf("stagingNames with a placementless and a home-named box = %q, want ie once", got) |
| + } |
| +} |
| + |
| +// Review Focus 4: the home fold is the move door's alone. placements(), |
| +// which feeds the create gate and armedPlacements, reads the same fleet as |
| +// it did before the fold. |
| +func TestCreatePlacementsUnchangedByHomeFold(t *testing.T) { |
| + ctx := context.Background() |
| + fx := newConsoleFixture(t) |
| + armMoveFleet(t, fx, caBox("ca-box"), homeFleetBox("home-box")) |
| + caps, published := placements(ctx, fx.rel) |
| + want := map[string]placementCapability{"ca": {Reachable: true, Hosting: true, Staging: true}} |
| + if !published || !reflect.DeepEqual(caps, want) { |
| + t.Fatalf("placements = %+v %v, want %+v true", caps, published, want) |
| + } |
| + set, published := armedPlacements(ctx, fx.rel) |
| + if !published || !reflect.DeepEqual(set, map[string]bool{"ca": true}) { |
| + t.Fatalf("armedPlacements = %v %v", set, published) |
| + } |
| + mcaps, homeNamed, mpublished := movePlacements(ctx, fx.rel, "ie") |
| + mwant := map[string]placementCapability{ |
| + "": {Reachable: true, Hosting: true, Staging: true}, |
| + "ca": {Reachable: true, Hosting: true, Staging: true}, |
| + } |
| + if !mpublished || len(homeNamed) != 0 || !reflect.DeepEqual(mcaps, mwant) { |
| + t.Fatalf("movePlacements = %+v %v %v, want %+v, no home-named box, published", mcaps, homeNamed, mpublished, mwant) |
| + } |
| + |
| + // A home-only fleet publishes no placement: create's gate stays dormant |
| + // and the move door still reads "publishes no armed regions". |
| + home := newConsoleFixture(t) |
| + armMoveFleet(t, home, homeFleetBox("home-box")) |
| + if caps, published := placements(ctx, home.rel); published || len(caps) != 0 { |
| + t.Fatalf("placements of a home-only fleet = %+v %v", caps, published) |
| + } |
| + if caps, _, published := movePlacements(ctx, home.rel, "ie"); published || !caps[""].Staging { |
| + t.Fatalf("movePlacements of a home-only fleet = %+v %v, want the home folded and nothing published", caps, published) |
| + } |
| + |
| + // Home-named boxes are listed, sorted; they count under their own code. |
| + named := newConsoleFixture(t) |
| + armMoveFleet(t, named, caBox("ca-box"), homeNamedBox("ie-z"), homeNamedBox("ie-a"), homeFleetBox("home-box")) |
| + mcaps, homeNamed, _ = movePlacements(ctx, named.rel, "ie") |
| + if !reflect.DeepEqual(homeNamed, []string{"ie-a", "ie-z"}) || !mcaps["ie"].Staging || !mcaps[""].Staging { |
| + t.Fatalf("movePlacements home-named = %v caps %+v", homeNamed, mcaps) |
| + } |
| + if _, homeNamed, _ := movePlacements(ctx, named.rel, ""); len(homeNamed) != 0 { |
| + t.Fatalf("with no home named nothing is home-named: %v", homeNamed) |
| + } |
| +} |
| + |
| +// Review Focus 5: a box that sets CARLOS_REGION to the home's name is not a |
| +// home box. The door refuses it with MV-N511, naming it, before the staging |
| +// check would answer a misleading MV-N20, and before any claim read or |
| +// registration. With a placementless home box beside it, the answer is the |
| +// same. |
| +func TestMoveDoorHomeTargetMustPublishNoPlacement(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + boxes []fleetBox |
| + }{ |
| + {"only a home-named box", []fleetBox{caBox("ca-box"), homeNamedBox("ie-box")}}, |
| + {"a home box and a home-named box", []fleetBox{caBox("ca-box"), homeFleetBox("home-box"), homeNamedBox("ie-box")}}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + fx, token, moveURL := homeDoor(t, "code-home-named", homeDoorOpts{}, tc.boxes...) |
| + var reads atomic.Int32 |
| + fx.srv.cfg.Releases = claimReadSpy{Store: fx.srv.cfg.Releases, reads: &reads} |
| + claim := claimBytes(t, fx, homeMoveHost) |
| + code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`) |
| + const want = "ie-box sets CARLOS_REGION, so it cannot take a move into the home region" |
| + if code != http.StatusConflict || errorText(out) != want { |
| + t.Fatalf("home-named target = %d %s, want 409 %q", code, out, want) |
| + } |
| + if reads.Load() != 0 { |
| + t.Fatalf("the refusal read the host claim %d times", reads.Load()) |
| + } |
| + // The spy sees the door's claim read (hostclaim.Read on |
| + // Config.Releases), so the zero above means something. |
| + if _, err := hostclaim.Read(context.Background(), fx.srv.cfg.Releases, homeMoveHost); err != nil || reads.Load() == 0 { |
| + t.Fatalf("the spy missed a claim read: %d %v", reads.Load(), err) |
| + } |
| + if homeMoveRegistrations(t, fx) != 0 || claimBytes(t, fx, homeMoveHost) != claim { |
| + t.Fatal("the refusal registered a move or wrote the claim") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Without the roster's home designation the claim cannot place a home |
| +// target: MV-N177, naming the home, and nothing registered. |
| +func TestMoveDoorHomeTargetNeedsTheDesignation(t *testing.T) { |
| + fx, token, moveURL := homeDoor(t, "code-home-designation", homeDoorOpts{noDesignation: true}, caBox("ca-box"), homeFleetBox("home-box")) |
| + code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`) |
| + const want = "the host roster does not allow this move. the source and target must be active boxes in ca and ie" |
| + if code != http.StatusConflict || errorText(out) != want { |
| + t.Fatalf("no designation = %d %s, want %q", code, out, want) |
| + } |
| + if homeMoveRegistrations(t, fx) != 0 { |
| + t.Fatal("a roster refusal registered a move") |
| + } |
| +} |
| + |
| +// The manifest allows a home target when an entry names the home; one that |
| +// does not refuses with MV-N2, naming the home. |
| +func TestMoveDoorManifestNamesHome(t *testing.T) { |
| + _, token, moveURL := homeDoor(t, "code-home-manifest", homeDoorOpts{regions: []string{"ie", "uk"}}, caBox("ca-box"), homeFleetBox("home-box")) |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`); code != http.StatusAccepted { |
| + t.Fatalf("regions [ie uk], --to ie = %d %s, want 202", code, out) |
| + } |
| + _, token, moveURL = homeDoor(t, "code-home-manifest-no", homeDoorOpts{regions: []string{"uk"}}, caBox("ca-box"), homeFleetBox("home-box")) |
| + code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`) |
| + const want = "region ie is not one of this app's regions: uk" |
| + if code != http.StatusBadRequest || errorText(out) != want { |
| + t.Fatalf("regions [uk], --to ie = %d %s, want %q", code, out, want) |
| + } |
| +} |
| + |
| +// Every refusal a home target can reach names the home by its name, with |
| +// its approved template unchanged. |
| +func TestMoveDoorHomeRefusalsNameTheHome(t *testing.T) { |
| + below := homeFleetBox("home-box") |
| + below.NoProtocol = true |
| + bare := homeFleetBox("home-box") |
| + bare.Bucket = "" |
| + unverified := homeFleetBox("home-box") |
| + unverified.Unverified = true |
| + for _, tc := range []struct { |
| + id string |
| + opts homeDoorOpts |
| + boxes []fleetBox |
| + code int |
| + want string |
| + }{ |
| + {"MV-N2", homeDoorOpts{regions: []string{"uk"}}, []fleetBox{caBox("ca-box"), homeFleetBox("home-box")}, |
| + http.StatusBadRequest, "region ie is not one of this app's regions: uk"}, |
| + {"MV-N5", homeDoorOpts{}, []fleetBox{caBox("ca-box"), below}, |
| + http.StatusConflict, "a box in ca or ie is below the moves protocol floor. roll the platform first"}, |
| + {"MV-N16", homeDoorOpts{recRegion: "ie", source: "home-box"}, []fleetBox{caBox("ca-box"), homeFleetBox("home-box")}, |
| + http.StatusConflict, "the instance is already in ie"}, |
| + {"MV-N20", homeDoorOpts{}, []fleetBox{caBox("ca-box"), bare}, |
| + http.StatusBadRequest, "region ie cannot take a move in this deployment. it publishes no staging bucket. Regions that can: ca"}, |
| + {"MV-N23", homeDoorOpts{}, []fleetBox{caBox("ca-box"), unverified}, |
| + http.StatusBadRequest, "no box in ie publishes an edges file yet"}, |
| + {"MV-N30 under @home", homeDoorOpts{chore: "@home"}, []fleetBox{caBox("ca-box"), homeFleetBox("home-box")}, |
| + http.StatusConflict, "the instance has unfinished cleanup in ie. wait for it to finish"}, |
| + {"MV-N30 under ie", homeDoorOpts{chore: "ie"}, []fleetBox{caBox("ca-box"), homeFleetBox("home-box")}, |
| + http.StatusConflict, "the instance has unfinished cleanup in ie. wait for it to finish"}, |
| + {"MV-N177", homeDoorOpts{noDesignation: true}, []fleetBox{caBox("ca-box"), homeFleetBox("home-box")}, |
| + http.StatusConflict, "the host roster does not allow this move. the source and target must be active boxes in ca and ie"}, |
| + } { |
| + t.Run(tc.id, func(t *testing.T) { |
| + fx, token, moveURL := homeDoor(t, "code-home-refusal", tc.opts, tc.boxes...) |
| + claim := claimBytes(t, fx, homeMoveHost) |
| + code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"ie"}`) |
| + got := errorText(out) |
| + if code != tc.code || got != tc.want { |
| + t.Fatalf("%s = %d %q, want %d %q", tc.id, code, got, tc.code, tc.want) |
| + } |
| + if strings.Contains(got, " in ") || strings.Contains(got, " or ") || strings.HasSuffix(got, " and ") { |
| + t.Fatalf("%s names an empty region: %q", tc.id, got) |
| + } |
| + if claimBytes(t, fx, homeMoveHost) != claim { |
| + t.Fatalf("%s wrote the claim", tc.id) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The legacy door (switch off) is not touched: its home refusal keeps |
| +// main's sentence, byte for byte. |
| +func TestLegacyMoveDoorStillRefusesHome(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-home-legacy") |
| + fx.srv.cfg.HomeRegion = "ie" |
| + m := titoManifest() |
| + m.Regions = nil |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if c, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"`+homeMoveHost+`","region":"uk"}`); c != http.StatusCreated { |
| + t.Fatalf("create = %d %s", c, out) |
| + } |
| + code, _, out := rawReq(t, http.MethodPost, u+"/"+homeMoveHost+"/move", token, `{"region":"ie"}`) |
| + if code != http.StatusBadRequest || errorText(out) != "moves to the home region are not supported yet — the home publishes no staging lane" { |
| + t.Fatalf("legacy home target = %d %s, want main's sentence", code, out) |
| + } |
| +} |
diff --git a/internal/console/boxselect.go b/internal/console/boxselect.go| index 605ff0a2e..d25bed47e 100644 |
| --- a/internal/console/boxselect.go |
| +++ b/internal/console/boxselect.go |
| @@ -52,12 +52,13 @@ var errEdgesIncomplete = errors.New("console: the fleet's edges files could not |
| // (account, and the AWS region expected for the code) plus the one-box |
| // refusal below (D15). Enroll's roster check (one instance under two |
| // references) is not run; |
| -// - awsRegion per code is s.regionCands(snap)'s AWSRegion (the majority |
| -// of the code's eligible boxes' AWS regions); a code with none has no |
| +// - awsRegion per code is regionCandidates' AWSRegion (the majority of |
| +// the code's eligible boxes' AWS regions); a code with none has no |
| // eligible box; |
| -// - homeName is s.cfg.HomeRegion and expectedFP is s.expectedSecretsFP(), |
| -// as regionCands passes them; lost is nil (Phase 1 consumes no loss |
| -// records, contract C6); |
| +// - homeName is s.moveHomeName() (the name homeRegionIn translates |
| +// with, so the door and the selection agree on a claim-mode fallback |
| +// home) and expectedFP is s.expectedSecretsFP(); lost is nil (Phase 1 |
| +// consumes no loss records, contract C6); |
| // - running is nil, and chooseBox is unchanged: its spread, load and |
| // hash tie-break preferences are never reached, because a region with |
| // two eligible boxes is refused before chooseBox could choose between |
| @@ -81,8 +82,9 @@ func (s *Server) selectMoveBoxes(ctx context.Context, host, to, from, app string |
| if err != nil { |
| return moveSelection{}, err |
| } |
| + home := s.moveHomeName() |
| aws := map[string]string{} |
| - for _, c := range s.regionCands(snap) { |
| + for _, c := range regionCandidates(snap, home, s.expectedSecretsFP()) { |
| aws[c.Code] = c.AWSRegion |
| } |
| roster, err := s.verifiedRoster(ctx, snap, aws, to, from) |
| @@ -112,7 +114,7 @@ func (s *Server) selectMoveBoxes(ctx context.Context, host, to, from, app string |
| } |
| fp := s.expectedSecretsFP() |
| for _, code := range []string{to, from} { |
| - cands := eligibleBoxes(snap, roster, code, aws[code], s.cfg.HomeRegion, fp, nil) |
| + cands := eligibleBoxes(snap, roster, code, aws[code], home, fp, nil) |
| if len(cands) > 1 { |
| sel.Many, sel.ManyRegion = true, s.homeRegionOut(code) |
| return sel, nil |
diff --git a/internal/console/instancesplacement.go b/internal/console/instancesplacement.go| index 394e55b5d..30898f3d8 100644 |
| --- a/internal/console/instancesplacement.go |
| +++ b/internal/console/instancesplacement.go |
| @@ -156,30 +156,61 @@ func placements(ctx context.Context, s release.Store) (caps map[string]placement |
| continue |
| } |
| published = true |
| - c := caps[f.Placement] |
| - // Every capability below is gated on reachability: a box with no |
| - // dial address contributes nothing, however well configured it is. |
| - // The code is still RECORDED, so an unreachable region reads as |
| - // known-and-unarmed rather than as absent. |
| - if f.EIP == "" { |
| - caps[f.Placement] = c |
| - continue |
| - } |
| - // Unknown is not no. An absent hibernation_configured with no |
| - // data_bucket is a writer that predates the field, and it keeps the |
| - // eligibility it had before the field existed. |
| - hosting := true |
| - if f.HibernationConfigured != nil { |
| - hosting = *f.HibernationConfigured |
| - } |
| - c.Reachable = true |
| - c.Hosting = c.Hosting || hosting |
| - c.Staging = c.Staging || f.DataBucket != "" |
| - caps[f.Placement] = c |
| + caps[f.Placement] = foldCapability(caps[f.Placement], f) |
| } |
| return caps, published |
| } |
| +// foldCapability adds one box's edges file to its code's capability. |
| +func foldCapability(c placementCapability, f edgesFilePlacementView) placementCapability { |
| + // Every capability below is gated on reachability: a box with no dial |
| + // address contributes nothing, however well configured it is. The code |
| + // is still RECORDED, so an unreachable region reads as known-and-unarmed |
| + // rather than as absent. |
| + if f.EIP == "" { |
| + return c |
| + } |
| + // Unknown is not no. An absent hibernation_configured with no |
| + // data_bucket is a writer that predates the field, and it keeps the |
| + // eligibility it had before the field existed. |
| + hosting := true |
| + if f.HibernationConfigured != nil { |
| + hosting = *f.HibernationConfigured |
| + } |
| + c.Reachable = true |
| + c.Hosting = c.Hosting || hosting |
| + c.Staging = c.Staging || f.DataBucket != "" |
| + return c |
| +} |
| + |
| +// movePlacements is the move door's reading of the same files: placements |
| +// plus the home, folded under the record's "" from every edges file that |
| +// carries no placement (a home box sets no CARLOS_REGION). The rules are |
| +// placements' own: no dial address, no capability, and staging is a |
| +// published data bucket. homeNamed is the sorted boxes whose file names |
| +// home as its placement (only when home is named): the console reads such a |
| +// box as home (homeRegionIn), but the box does not, so it cannot take a |
| +// move into home (MV-N511). published keeps placements' meaning, so a fleet |
| +// whose only boxes are the home's still publishes no armed regions. |
| +// |
| +// A separate reader, not a flag on placements (Review Focus 4): |
| +// placements feeds the create gate's published flag and armedPlacements, |
| +// and a home folded in there would change create. |
| +func movePlacements(ctx context.Context, s release.Store, home string) (caps map[string]placementCapability, homeNamed []string, published bool) { |
| + caps = map[string]placementCapability{} |
| + for _, f := range readEdgesFiles(ctx, s) { |
| + if f.Placement != "" { |
| + published = true |
| + if homePlacementCode(home, f.Placement) == "" { |
| + homeNamed = append(homeNamed, f.Box) |
| + } |
| + } |
| + caps[f.Placement] = foldCapability(caps[f.Placement], f) |
| + } |
| + sort.Strings(homeNamed) |
| + return caps, homeNamed, published |
| +} |
| + |
| // armedPlacements is the picker answer in the shape its callers have always |
| // taken: code -> "can take an instance right now", which is Reachable AND |
| // Hosting. |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index f67841e41..fe1002533 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -747,11 +747,15 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| // Twelve changed or new units re-frozen at main's dispositions, ten of |
| // them legacy, add ten frozen declarations; Task 4's revert fix adds |
| // revertRun.probing, frozen at its sibling units' disposition, an |
| - // eleventh. Ruling owed: the Plan 14 owner has not yet approved it. |
| + // eleventh. Task 5's move door (a home target under host claims) |
| + // re-freezes Server.handleAPIInstanceMoveRequest and |
| + // Server.startClaimModeMove at main's dispositions, read sets |
| + // unchanged, two more. Ruling owed: the Plan 14 owner has not yet |
| + // approved it. |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-home-part2.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| - seal = "c0acbd4ad21d26fa77a7b456242bedc6c2d3e2c4a956f05360f9fc5ee874b9b2" |
| + seal = "e17438842abcd04266d682f10aae7004d81ce25e6c5d96bcacb2337d4f5b3280" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 4809da1ab..9f41b3f6a 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -656,7 +656,8 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/console/apidoorkeys.go", "Server.publishDoorKeysLocked", "foreign-recordkey", "key:RecordKey,sel:Put", "0fc498cd4af50570a0b7ed3ff71994213cab40040f6701a303bdf85d94f958da"}, |
| {"internal/console/apiinstancemove.go", "Server.moveTransitionSaying", "record-writer", "key:SetRecordMove", "d1f7162eecdc234607c708a83e9633cdf77cc6931bac6754fb51042dd8409377"}, |
| // Re-reviewed at the merge of carlos-run into seeded-instances (2026-10-13): main's door with the seed rule (refuseSeedMove) after its gate and before the transition; the transition is unchanged and refuses the same seed again under its CAS. |
| - {"internal/console/apiinstancemove.go", "Server.handleAPIInstanceMoveRequest", "record-writer", "key:SetRecordMove,lit:instances", "d4350f8b160076e4e69a0e6fdb5da9a042f4445a7c768dee49be0f6281fc20f3"}, |
| + // Re-reviewed for home part 2, Task 5 (ruling owed): a home target is refused on the plain path (MV-N510) and for a home-named box (MV-N511) before any write, and admitted under host claims through startClaimModeMove; its record writes are unchanged. |
| + {"internal/console/apiinstancemove.go", "Server.handleAPIInstanceMoveRequest", "record-writer", "key:SetRecordMove,lit:instances", "79483d9603c096a269042d7b250e328761f9de7f365c272b82d34b16579b0417"}, |
| {"internal/console/apiinstancemove.go", "Server.registerMovesPhase1Routes", "key-site", "lit:instances", "5af589d288ba22856f631481c40cc001aac342ea74cee6ee0464ec77d0320bbc"}, |
| {"internal/console/apiinstances.go", "Server.instanceManifest", "key-site", "lit:instances", "e6160ffe84abda9163527ccb33e2ffff2109b35908adb2f3a754534325658fdf"}, |
| {"internal/console/apiinstances.go", "Server.handleAPIInstanceList", "key-site", "lit:instances", "558d0b3e7b0f06faa8e5067ff6f8c131eb899f13d08b71218dfb2454c3e319e1"}, |
diff --git a/internal/provision/testdata/plan14-root-reads-home-part2.tsv b/internal/provision/testdata/plan14-root-reads-home-part2.tsv| index 8c5914385..acb57fe2e 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-home-part2.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-home-part2.tsv |
| @@ -4,6 +4,7 @@ |
| # Re-frozen units, every one at main's disposition (class, task and basis unchanged; kinds and digest refreshed); these rows replace this overlay's earlier, unruled rows. Authority rows, read set unchanged (only source offsets moved): host/claimmoveaccess.go claimMoveLaunchSource (authority C,G,J1; it calls LaunchLayers and reports the env from that composition) and host/movespass.go armPhase1 (authority B2,C; its LaunchEnv hook returns the layer names). Legacy rows: activator ExecBackend.LaunchEnvironment (a wrapper of LaunchLayers) and the new ExecBackend.LaunchLayers (LaunchEnvironment's composition and reads, moved; LaunchEnvironment's disposition); host movesWorker.writeSourceReport (ReportLaunch with the layer names); movesProbeOwners.Select, Materialize, selectCommitted and materializeCommitted (the captured report's pin, v2 only); movesRecoveryOwners.Select and Materialize (localEnvDigest); moveswakesource.go movesReadLaunch (passes the access's report through). No new read and no root decision. Ten frozen legacy declarations are added. |
| # Fix round 5: the probe owners' captured pins live as long as their wake (holdEnvPin from Materialize, under the start loop's context) and never past the wake's deadline (captureEnvPin, pruneEnvPins); movesProbeOwners.Select, Materialize and selectCommitted re-frozen again, read sets unchanged. |
| # Task 4 (a defect its revert witness found): the revert's probing step reads the host claim again after its probe answers, because a real recovery probe wake writes the claim (its admission and attempt reservation) and the step's own read went stale, so its probed CAS lost every pass. revertRun.probing gains that one read (file:Read, hostclaim.Read of the control store) and is new to the census; it is frozen at the disposition main gives the same read in its sibling units (revertRun.step and recoverRun.claim: legacy, accepted-legacy;rev3=J4;basis=root-input). One frozen declaration is added; the seal moves. |
| +# Task 5: the Phase 1 move door admits a home target under host claims. Server.handleAPIInstanceMoveRequest gains its home refusals before any write (MV-N510 without host claims, MV-N511 for a home-named box, read from the same edges-file listing through movePlacements) and its sentences name the home; Server.startClaimModeMove's sentences name the home. Read sets unchanged (the same file:Exec and file:Read kinds), only offsets moved: both re-frozen at main's disposition (class, task and basis unchanged; kinds and digest refreshed), they add two frozen declarations; the seal moves. internal/console's source envelope is refreshed. |
| @legacy 849ce111c61c3dcedd80595348d0a26c67fb47462d497fba63d52d2c94e1d5d0 internal/activator/backend_exec.go ExecBackend.LaunchEnvironment legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route whole:Route@139 e8853e15b427b80a701b5e10b565f7d4f0b778e0c8185da63266e2408f6b4113 |
| internal/activator/backend_exec.go ExecBackend.LaunchEnvironment legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route whole:Route@139 e8853e15b427b80a701b5e10b565f7d4f0b778e0c8185da63266e2408f6b4113 |
| @legacy 1680e7269c40013e72c2f0a39abc13ee75cecadf2b3b8aa3d3a0278c060458ce internal/activator/backend_exec.go ExecBackend.LaunchLayers legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:Socket@751,whole:Route@179,whole:Route@747 66a14ac6d0a4c2c9333adf45a792407d8cc4fa389416feb31fcf58616fc5b611 |
| @@ -28,6 +29,11 @@ internal/host/movesrecoveryowners.go movesRecoveryOwners.Select legacy accepted- |
| internal/host/moveswakesource.go movesReadLaunch legacy accepted-legacy accepted-legacy;rev3=merge-ruling-2;basis=Moves launch-input adapter selects by host through existing read interface; generic launch payload selection remains legacy. registry:Host@258 780a60ccf1035f2c7d64fdc804a867b64d380b05a51b151155ca6efe70b01767 |
| @legacy d0a0ae52a0459fe62264eeb3b0cf906548b4afe6a8f23a4d1bae562b3ca286c0 internal/host/movesrevert.go revertRun.probing legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Read@2524 2eb21ddc4f49a08292f687f6f6e22b1c70facaf3a71a2d7a118583fbb278e5e3 |
| internal/host/movesrevert.go revertRun.probing legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Read@2524 2eb21ddc4f49a08292f687f6f6e22b1c70facaf3a71a2d7a118583fbb278e5e3 |
| +@legacy 5923daef3bc024b71d20d654c5afd9e78c93f9ccd00508507cc39f1cf083e14f internal/console/apiinstancemove.go Server.handleAPIInstanceMoveRequest legacy accepted-legacy accepted-legacy;rev3=census-ruling-seeded-instances;basis=the console (cmd/console serve under console.service, user carlos-console): main's body with one more refusal before any write: a seed that may not move yet (refuseSeedMove); read sites unchanged, offsets moved file:Exec@10698,file:Exec@8291,file:Exec@8379,file:Exec@9370,file:Exec@9547,file:Exec@9698,file:Read@6777,file:Read@9089 79483d9603c096a269042d7b250e328761f9de7f365c272b82d34b16579b0417 |
| +internal/console/apiinstancemove.go Server.handleAPIInstanceMoveRequest legacy accepted-legacy accepted-legacy;rev3=census-ruling-seeded-instances;basis=the console (cmd/console serve under console.service, user carlos-console): main's body with one more refusal before any write: a seed that may not move yet (refuseSeedMove); read sites unchanged, offsets moved file:Exec@10698,file:Exec@8291,file:Exec@8379,file:Exec@9370,file:Exec@9547,file:Exec@9698,file:Read@6777,file:Read@9089 79483d9603c096a269042d7b250e328761f9de7f365c272b82d34b16579b0417 |
| +@legacy 3bbe043ae854375a257b0425b0ee82ce9c2146b9bcbcbc3f88aa3ed6c4fe5bfc internal/console/apiinstancemove.go Server.startClaimModeMove legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Exec@3526,file:Exec@565 c4e47ebd1e84c89d72ed8bbb25e65af8e365f4993827682ca55de9fd2d2d90bb |
| +internal/console/apiinstancemove.go Server.startClaimModeMove legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Exec@3526,file:Exec@565 c4e47ebd1e84c89d72ed8bbb25e65af8e365f4993827682ca55de9fd2d2d90bb |
| @coverage internal/activator 3e4fae49c0669c0524ec2cadcedf5d86de1bc10df1ebe4ad370d99b5712819f1 |
| +@coverage internal/console a565401e44e7797120ad52136146d84c8e0d430ceeb6a6f1c5e1ea0ed7a78c7e |
| @coverage internal/host fa686f59b817d104447d32ab419a98ebb44e30d8c91880bdb8475c9dcdba71f4 |
| @coverage internal/moves 51ae19338bf9f92d45cc166f9fb71cdd3280c617892f71bb9ce67b2b00a7ae27 |