moves: T3 accepts a home target that sets no region (home part 2)
moves.CompareEnv takes targetHome. For a home target the target must not carry CARLOS_REGION (absent is no diff, present is region-present, checked in both loops); a named target keeps region-missing and region-same. checkEnv passes rec.Move.To == "", never cfg.Region. Copy: MV-N512 (region-present) pending. Census: envelope-only overlay plan14-root-reads-home-part2.tsv, ruling owed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+245
−17
approved_moves_copy_test.go+1 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+8 −0internal/host/moveshome_target_test.go+104 −0internal/host/movesreport.go+5 −1internal/moves/envreport.go+20 −2internal/moves/envreport_instancekey_test.go+2 −2internal/moves/envreport_test.go+94 −12internal/provision/plan14_root_reads_test.go+7 −0internal/provision/testdata/plan14-root-reads-home-part2.tsv+4 −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 296.781s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.826s
ok github.com/carlosframework/platform/cmd/moveprobe 93.232s
ok github.com/carlosframework/platform/cmd/zddprobe 0.705s
ok github.com/carlosframework/platform/internal/activator 249.625s
ok github.com/carlosframework/platform/internal/agentsock 0.018s
ok github.com/carlosframework/platform/internal/alerting 0.004s
ok github.com/carlosframework/platform/internal/analytics 0.022s
ok github.com/carlosframework/platform/internal/appconfig 2.486s
ok github.com/carlosframework/platform/internal/apperr 0.075s
ok github.com/carlosframework/platform/internal/applog 0.137s
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.639s
? 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.695s
ok github.com/carlosframework/platform/internal/changelog 0.306s
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 390.997s
? 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.300s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.002s
ok github.com/carlosframework/platform/internal/docsite 0.004s
ok github.com/carlosframework/platform/internal/domains 2.426s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 26.500s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.131s
ok github.com/carlosframework/platform/internal/gate 1.654s
ok github.com/carlosframework/platform/internal/genselect 0.014s
ok github.com/carlosframework/platform/internal/geo 0.015s
ok github.com/carlosframework/platform/internal/geoip 0.030s
ok github.com/carlosframework/platform/internal/host 135.196s
ok github.com/carlosframework/platform/internal/hostactors 0.427s
ok github.com/carlosframework/platform/internal/hostbox 2.284s
ok github.com/carlosframework/platform/internal/hostclaim 0.533s
ok github.com/carlosframework/platform/internal/hostevidence 0.018s
ok github.com/carlosframework/platform/internal/hostexec 4.850s
ok github.com/carlosframework/platform/internal/hostfence 0.030s
ok github.com/carlosframework/platform/internal/hostfiles 10.513s
ok github.com/carlosframework/platform/internal/hostfloor 0.017s
ok github.com/carlosframework/platform/internal/hostguard 1.013s
ok github.com/carlosframework/platform/internal/hostops 1.582s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.017s
ok github.com/carlosframework/platform/internal/hostquarantine 0.017s
ok github.com/carlosframework/platform/internal/hostrelocate 0.450s
ok github.com/carlosframework/platform/internal/hostresolve 0.134s
ok github.com/carlosframework/platform/internal/hostwrite 0.017s
ok github.com/carlosframework/platform/internal/infraview 0.017s
ok github.com/carlosframework/platform/internal/instancectl 4.160s
ok github.com/carlosframework/platform/internal/instanceexec 2.742s
ok github.com/carlosframework/platform/internal/instancefs 0.017s
ok github.com/carlosframework/platform/internal/instancekey 0.020s
ok github.com/carlosframework/platform/internal/instanceupdates 0.025s
ok github.com/carlosframework/platform/internal/layout 1.557s
ok github.com/carlosframework/platform/internal/lease 9.829s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.025s
ok github.com/carlosframework/platform/internal/ledger 0.010s
ok github.com/carlosframework/platform/internal/mail 0.200s
ok github.com/carlosframework/platform/internal/maintenance 0.089s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.524s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.072s
ok github.com/carlosframework/platform/internal/metrics 0.007s
ok github.com/carlosframework/platform/internal/moves 21.010s
ok github.com/carlosframework/platform/internal/moves/archive 0.767s
ok github.com/carlosframework/platform/internal/objstore 0.187s
ok github.com/carlosframework/platform/internal/ops 0.175s
ok github.com/carlosframework/platform/internal/park 0.225s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 15.851s
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.905s
ok github.com/carlosframework/platform/internal/relay 7.234s
ok github.com/carlosframework/platform/internal/release 19.892s
ok github.com/carlosframework/platform/internal/rollout 3.713s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.007s
ok github.com/carlosframework/platform/internal/rootstate 0.027s
ok github.com/carlosframework/platform/internal/runs 0.566s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.494s
ok github.com/carlosframework/platform/internal/seeds 0.010s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.550s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.914s
ok github.com/carlosframework/platform/internal/status 0.038s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.231s
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.006s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
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.561s
=== 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: 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.025s
=== RUN TestT4StorageProbeMinio
2026/10/15 15:22:58 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEBD3E8D8AEF10, 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 c4d349fce..df5132644 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -89,6 +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", |
| } |
| // 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 e44720f29..89da8c052 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -448,3 +448,11 @@ Paul's copy review of 2026-10-12 (region HA stage B, final review minor 9). The |
| | MV-N500 | internal/console/apiinstancemove.go | the instance's last relocation is still finishing. try again in a few minutes | approved | |
| The five rows added after Plan 12 (MV-N450, MV-N451, MV-N454, MV-N455 and MV-N456) were approved by Paul on 2026-10-13 with no edits. |
| + |
| +## Moves into home (part 2) |
| + |
| +Plan `docs/superpowers/plans/2026-10-15-moves-home-region-part2.md` (amadan #236). Its rows take MV-N510 to MV-N529, each a pending row in the commit of the task that writes it. Task 1's row is T3's new kind. A home box sets no `CARLOS_REGION`, so a move into home fails T3 only when the target does set one: that box is not the home box. `region-present` is a fill-in inside MV-N129's list (`<NAME (kind)>, ...`), so it has its own row, as MV-N146's fill-in does. |
| + |
| +| id | file | text | status | |
| +|---|---|---|---| |
| +| MV-N512 | internal/moves/envreport.go (a T3 kind inside MV-N129's list) | region-present | pending | |
diff --git a/internal/host/moveshome_target_test.go b/internal/host/moveshome_target_test.go| new file mode 100644 |
| index 000000000..03019b450 |
| --- /dev/null |
| +++ b/internal/host/moveshome_target_test.go |
| @@ -0,0 +1,104 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// startIntoHomeT3 writes the record and claim for a move from the harness's |
| +// named source (ie-box in ie) into the home placement, whose box is |
| +// home-box: the record's move names the home's code, "", and the claim's |
| +// target region is the placement-derived engine region, @home. The home box |
| +// publishes its edges file with no placement, as a home box does. |
| +func startIntoHomeT3(t *testing.T, h *phase1Harness) { |
| + t.Helper() |
| + ctx := context.Background() |
| + if h.src == "" { |
| + t.Fatal("startIntoHomeT3 needs a named source") |
| + } |
| + if _, err := h.s.Put(ctx, "control/edges/home-box.json", |
| + []byte(`{"v":1,"box":"home-box","eip":"1.2.3.9","data_bucket":"data-home","data_bucket_region":"eu-west-1","hosts":{}}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := provision.Record{Schema: 1, Host: p1Host, Slug: "s", App: "app", Account: "bab", Region: h.src, Channel: "stable", CreatedAt: p1Created} |
| + if err := provision.CreateRecord(ctx, h.s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveStartTransition(provision.MoveStart{ |
| + ID: p1ID, Incarnation: h.inc, From: h.src, To: "", Cut: "manual", By: "op", Now: h.clock.Now()})); err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err := hostclaim.Update(ctx, h.s, hostclaim.Observed{Host: p1Host, Absent: true}, func(c *hostclaim.Claim) error { |
| + c.Holder = &hostclaim.Holder{Family: "instance", Key: provision.RecordKey("bab", "app", p1Host), Account: "bab", App: "app", |
| + Incarnation: h.inc, CreatedAt: p1Created, Location: "instances/" + p1Host + "/" + h.inc, Generation: "cur", Stamp: 1} |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "ie-box"} |
| + c.Touched = hostclaim.Touched{Complete: true, Regions: []string{park.PlacementRegion(h.src)}, Boxes: []string{"ie-box"}} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := hostclaim.Update(ctx, h.s, obs, hostclaim.BeginMove(p1ID, "op", "ie-box", "home-box", park.PlacementRegion(""), h.clock.Now())); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// A home box sets no CARLOS_REGION: agentOwnedVars adds it only when the |
| +// box has a region, and removes a tenant-provided one otherwise. T3 runs |
| +// here on both sides' production compositions: the source's launch report |
| +// is its ExecBackend's LaunchEnvironment with region ie, and the home box's |
| +// held environment is composeHeldEnv with the home box's own region, "", as |
| +// armPhase1 wires it. Before home part 2's T3 rule the target lacked the |
| +// source's CARLOS_REGION and failed region-missing; the harness's default |
| +// fixture sets CARLOS_REGION=uk and would have hidden that. |
| +func TestHomeTargetT3FromTheRealComposition(t *testing.T) { |
| + h := newPhase1Harness(t) |
| + src := &activator.ExecBackend{DataDir: h.cfg.DataDir, Region: h.src} |
| + h.cfg.LaunchEnv = func(string) (map[string]string, []string, error) { return src.LaunchEnvironment(h.route) } |
| + startIntoHomeT3(t, h) |
| + h.pass(t) |
| + if _, ok := h.sourceReport(t).Env.Vars[moves.RegionVar]; !ok { |
| + t.Fatal("the named source's launch report carries no CARLOS_REGION; the witness needs it") |
| + } |
| + |
| + tc := h.targetCfg() |
| + tc.Region, tc.Box, tc.OwnBucket = "", "home-box", "data-home" |
| + tc.ComposeHeldEnv = composeHeldEnv(tc.SecretsPriv, tc.Region, false) |
| + h.targetPass(t, tc) |
| + r := h.targetReport(t) |
| + if r.Box != "home-box" || r.Region != "" { |
| + t.Fatalf("the report is not the home box's: box %q region %q", r.Box, r.Region) |
| + } |
| + if t3 := r.Checks[moves.CheckEnv]; !t3.OK { |
| + t.Fatalf("T3 on a home target that sets no region: %+v", t3) |
| + } |
| + if r.Inputs.EnvReport != h.sourceReport(t).Env.Digest { |
| + t.Fatalf("a passing T3 binds the source's env report: %q", r.Inputs.EnvReport) |
| + } |
| +} |
| + |
| +// A box that sets CARLOS_REGION is not the home box, even when a move into |
| +// home names it: T3 fails it region-present (Review Focus 5), here with the |
| +// same production composer given a region. |
| +func TestHomeTargetT3RefusesABoxThatSetsARegion(t *testing.T) { |
| + h := newPhase1Harness(t) |
| + src := &activator.ExecBackend{DataDir: h.cfg.DataDir, Region: h.src} |
| + h.cfg.LaunchEnv = func(string) (map[string]string, []string, error) { return src.LaunchEnvironment(h.route) } |
| + startIntoHomeT3(t, h) |
| + h.pass(t) |
| + |
| + tc := h.targetCfg() |
| + tc.Region, tc.Box, tc.OwnBucket = "", "home-box", "data-home" |
| + tc.ComposeHeldEnv = composeHeldEnv(tc.SecretsPriv, "uk", false) |
| + h.targetPass(t, tc) |
| + t3 := h.targetReport(t).Checks[moves.CheckEnv] |
| + if want := t3Differs([]string{moves.RegionVar + " (region-present)"}); t3.OK || t3.Reason != want { |
| + t.Fatalf("T3 = %+v, want %q", t3, want) |
| + } |
| +} |
diff --git a/internal/host/movesreport.go b/internal/host/movesreport.go| index 0a2c8a1d8..913ac66e2 100644 |
| --- a/internal/host/movesreport.go |
| +++ b/internal/host/movesreport.go |
| @@ -510,7 +510,11 @@ func (w *movesWorker) checkEnv(ctx context.Context, s release.Store, rec provisi |
| for _, name := range shadowed { |
| notes = append(notes, t3Shadowed(name)) |
| } |
| - for _, d := range moves.CompareEnv(source.Env, moves.ReportEnv(key, env), true) { |
| + // A move into home names the home's code, "", on the record: T3's home |
| + // rule follows the record, never this box's region, so a second |
| + // placementless box stays T8's failure and is not T3's pass. |
| + targetHome := rec.Move.To == "" |
| + for _, d := range moves.CompareEnv(source.Env, moves.ReportEnv(key, env), true, targetHome) { |
| if d.Note() { |
| notes = append(notes, d.Name+" ("+d.Kind+")") |
| continue |
diff --git a/internal/moves/envreport.go b/internal/moves/envreport.go| index 24ea7a4e8..30424aaa9 100644 |
| --- a/internal/moves/envreport.go |
| +++ b/internal/moves/envreport.go |
| @@ -114,7 +114,15 @@ func (d EnvDiff) Note() bool { return d.Kind == "advisory-differs" } |
| // variables report a presence difference as a note (EnvDiff.Note), box |
| // variables are not compared, everything else must be equal by HMAC. T3 |
| // fails on any diff that is not a note. |
| -func CompareEnv(source, target EnvReport, flatLayout bool) []EnvDiff { |
| +// |
| +// targetHome is a move into the home placement (the record's move names |
| +// the home's code, ""). A home box sets no CARLOS_REGION (the agent adds it |
| +// only when the box has a region), so for a home target the rule turns |
| +// round: the target must not carry it, whatever the source carries, and a |
| +// target that does is not the home box (region-present). For a named |
| +// target the rule is unchanged: it must set its own, different from the |
| +// source's. |
| +func CompareEnv(source, target EnvReport, flatLayout, targetHome bool) []EnvDiff { |
| var diffs []EnvDiff |
| seen := map[string]bool{} |
| for name, s := range source.Vars { |
| @@ -122,7 +130,11 @@ func CompareEnv(source, target EnvReport, flatLayout bool) []EnvDiff { |
| tv, ok := target.Vars[name] |
| switch Class(name) { |
| case "region": |
| - if !ok { |
| + if targetHome { |
| + if ok { |
| + diffs = append(diffs, EnvDiff{name, "region-present"}) // a home box sets no region |
| + } |
| + } else if !ok { |
| diffs = append(diffs, EnvDiff{name, "region-missing"}) // the target must set its own region |
| } else if tv == s { |
| diffs = append(diffs, EnvDiff{name, "region-same"}) |
| @@ -152,6 +164,12 @@ func CompareEnv(source, target EnvReport, flatLayout bool) []EnvDiff { |
| continue |
| } |
| switch Class(name) { |
| + case "region": |
| + // a target-only region is a named target's own (a move out of |
| + // home); on a home target it is a box that is not the home box |
| + if targetHome { |
| + diffs = append(diffs, EnvDiff{name, "region-present"}) |
| + } |
| case "equal": |
| diffs = append(diffs, EnvDiff{name, "extra"}) |
| case "advisory": |
diff --git a/internal/moves/envreport_instancekey_test.go b/internal/moves/envreport_instancekey_test.go| index f38e07f42..335b125e4 100644 |
| --- a/internal/moves/envreport_instancekey_test.go |
| +++ b/internal/moves/envreport_instancekey_test.go |
| @@ -21,12 +21,12 @@ func TestEnvReportComparesTheInstanceKey(t *testing.T) { |
| } { |
| // The kind is the whole check: T3 (movesreport.go) fails on every |
| // kind but advisory-differs, which only an advisory variable gets. |
| - diffs := CompareEnv(src, ReportEnv(key, tc.target), false) |
| + diffs := CompareEnv(src, ReportEnv(key, tc.target), false, false) |
| if len(diffs) != 1 || diffs[0].Name != name || diffs[0].Kind != tc.want { |
| t.Fatalf("diffs = %+v, want one %s on %s", diffs, tc.want, name) |
| } |
| } |
| - if diffs := CompareEnv(src, ReportEnv(key, map[string]string{name: "a2V5LW9uZQ==", "CARLOS_REGION": "uk"}), false); len(diffs) != 0 { |
| + if diffs := CompareEnv(src, ReportEnv(key, map[string]string{name: "a2V5LW9uZQ==", "CARLOS_REGION": "uk"}), false, false); len(diffs) != 0 { |
| t.Fatalf("the same key reported a difference: %+v", diffs) |
| } |
| } |
diff --git a/internal/moves/envreport_test.go b/internal/moves/envreport_test.go| index f5739830f..18cb17434 100644 |
| --- a/internal/moves/envreport_test.go |
| +++ b/internal/moves/envreport_test.go |
| @@ -13,23 +13,23 @@ func TestEnvReportRevealsNamesAndEqualityOnly(t *testing.T) { |
| t.Fatal("a value leaked or the mac is not hex sha256") |
| } |
| same := ReportEnv(key, map[string]string{"DATABASE_URL": "sqlite:a", "CARLOS_REGION": "uk", "CARLOS_CONTROL_SOCKET": "/run/y", "INVOCATION_ID": "2", "SECRET": "s3"}) |
| - if diffs := CompareEnv(src, same, true); len(diffs) != 0 { |
| + if diffs := CompareEnv(src, same, true, false); len(diffs) != 0 { |
| t.Fatalf("a matching target reported %v", diffs) |
| } |
| // Region must differ; layout vars are present iff flat provides them; |
| // box vars are never compared; everything else equal. |
| sameRegion := ReportEnv(key, map[string]string{"DATABASE_URL": "sqlite:a", "CARLOS_REGION": "ie", "CARLOS_CONTROL_SOCKET": "/run/y", "SECRET": "s3"}) |
| - diffs := CompareEnv(src, sameRegion, true) |
| + diffs := CompareEnv(src, sameRegion, true, false) |
| if len(diffs) != 1 || diffs[0].Name != "CARLOS_REGION" || diffs[0].Kind != "region-same" { |
| t.Fatalf("same region: %v", diffs) |
| } |
| noRegion := ReportEnv(key, map[string]string{"DATABASE_URL": "sqlite:a", "CARLOS_CONTROL_SOCKET": "/run/y", "SECRET": "s3"}) |
| - if diffs := CompareEnv(src, noRegion, true); len(diffs) != 1 || diffs[0].Kind != "region-missing" { |
| + if diffs := CompareEnv(src, noRegion, true, false); len(diffs) != 1 || diffs[0].Kind != "region-missing" { |
| t.Fatalf("missing region: %v", diffs) |
| } |
| // the layout variable absent from both: still required of a flat landing |
| srcNoLayout := ReportEnv(key, map[string]string{"DATABASE_URL": "sqlite:a", "CARLOS_REGION": "ie"}) |
| - if diffs := CompareEnv(srcNoLayout, ReportEnv(key, map[string]string{"DATABASE_URL": "sqlite:a", "CARLOS_REGION": "uk"}), true); len(diffs) != 1 || diffs[0].Name != "CARLOS_CONTROL_SOCKET" || diffs[0].Kind != "layout-missing" { |
| + if diffs := CompareEnv(srcNoLayout, ReportEnv(key, map[string]string{"DATABASE_URL": "sqlite:a", "CARLOS_REGION": "uk"}), true, false); len(diffs) != 1 || diffs[0].Name != "CARLOS_CONTROL_SOCKET" || diffs[0].Kind != "layout-missing" { |
| t.Fatalf("layout name absent from both: %v", diffs) |
| } |
| // CARLOS_PURGE is advisory (set iff the box has a cache): a presence |
| @@ -37,12 +37,12 @@ func TestEnvReportRevealsNamesAndEqualityOnly(t *testing.T) { |
| // box without a cache (backend_exec.go agentOwnedVars), so neither |
| // example above carries it and they compare clean. |
| purge := ReportEnv(key, map[string]string{"DATABASE_URL": "sqlite:a", "CARLOS_REGION": "uk", "CARLOS_CONTROL_SOCKET": "/run/y", "SECRET": "s3", "CARLOS_PURGE": "header"}) |
| - if diffs := CompareEnv(src, purge, true); len(diffs) != 1 || diffs[0].Name != "CARLOS_PURGE" || diffs[0].Kind != "advisory-differs" || !diffs[0].Note() { |
| + if diffs := CompareEnv(src, purge, true, false); len(diffs) != 1 || diffs[0].Name != "CARLOS_PURGE" || diffs[0].Kind != "advisory-differs" || !diffs[0].Note() { |
| t.Fatalf("purge presence: %v", diffs) |
| } |
| drift := ReportEnv(key, map[string]string{"DATABASE_URL": "sqlite:b", "CARLOS_REGION": "uk", "EXTRA": "x"}) |
| kinds := map[string]string{} |
| - for _, d := range CompareEnv(src, drift, false) { |
| + for _, d := range CompareEnv(src, drift, false, false) { |
| kinds[d.Name] = d.Kind |
| } |
| if kinds["DATABASE_URL"] != "differs" || kinds["SECRET"] != "missing" || kinds["EXTRA"] != "extra" || kinds["CARLOS_CONTROL_SOCKET"] != "" { |
| @@ -69,7 +69,7 @@ func TestEnvReportClassesAndNotes(t *testing.T) { |
| if len(LayoutVars) != 1 || !LayoutVars["CARLOS_CONTROL_SOCKET"] || len(AdvisoryVars) != 1 || !AdvisoryVars["CARLOS_PURGE"] { |
| t.Fatalf("layout %v advisory %v", LayoutVars, AdvisoryVars) |
| } |
| - for _, k := range []string{"missing", "extra", "differs", "region-missing", "region-same", "layout-missing", "layout-present"} { |
| + for _, k := range []string{"missing", "extra", "differs", "region-missing", "region-same", "region-present", "layout-missing", "layout-present"} { |
| if (EnvDiff{"X", k}).Note() { |
| t.Errorf("%s is a note", k) |
| } |
| @@ -86,28 +86,110 @@ func TestCompareEnvNestedLayout(t *testing.T) { |
| // T3's "present iff the flat layout provides it": a layout variable |
| // only the target carries is a diff on a non-flat landing, and |
| // nothing on a flat one. |
| - if diffs := CompareEnv(src, tgt, false); len(diffs) != 1 || diffs[0].Name != "CARLOS_CONTROL_SOCKET" || diffs[0].Kind != "layout-present" { |
| + if diffs := CompareEnv(src, tgt, false, false); len(diffs) != 1 || diffs[0].Name != "CARLOS_CONTROL_SOCKET" || diffs[0].Kind != "layout-present" { |
| t.Fatalf("target-only layout var, non-flat: %v", diffs) |
| } |
| - if diffs := CompareEnv(src, tgt, true); len(diffs) != 0 { |
| + if diffs := CompareEnv(src, tgt, true, false); len(diffs) != 0 { |
| t.Fatalf("target-only layout var, flat: %v", diffs) |
| } |
| src2 := ReportEnv(key, map[string]string{"A": "1", "CARLOS_REGION": "ie", "CARLOS_CONTROL_SOCKET": "/run/x"}) |
| - if diffs := CompareEnv(src2, tgt, false); len(diffs) != 1 || diffs[0].Kind != "layout-present" { |
| + if diffs := CompareEnv(src2, tgt, false, false); len(diffs) != 1 || diffs[0].Kind != "layout-present" { |
| t.Fatalf("layout-present: %v", diffs) |
| } |
| // neither side carries it, non-flat: clean |
| none := ReportEnv(key, map[string]string{"A": "1", "CARLOS_REGION": "uk"}) |
| - if diffs := CompareEnv(src, none, false); len(diffs) != 0 { |
| + if diffs := CompareEnv(src, none, false, false); len(diffs) != 0 { |
| t.Fatalf("no layout var anywhere, non-flat: %v", diffs) |
| } |
| // the advisory exception holds: a target-only CARLOS_PURGE is a note |
| purge := ReportEnv(key, map[string]string{"A": "1", "CARLOS_REGION": "uk", "CARLOS_PURGE": "header"}) |
| - if diffs := CompareEnv(src, purge, false); len(diffs) != 1 || !diffs[0].Note() { |
| + if diffs := CompareEnv(src, purge, false, false); len(diffs) != 1 || !diffs[0].Note() { |
| t.Fatalf("target-only purge, non-flat: %v", diffs) |
| } |
| } |
| +// T3's home rule (home part 2, Review Focus 2): a home target sets no |
| +// CARLOS_REGION (agentOwnedVars adds it only when the box has a region), so |
| +// absent is no diff; a target in home that does set one is not the home |
| +// box, and fails region-present. The same pair compared as a named target |
| +// keeps today's region-missing. |
| +func TestCompareEnvHomeTargetSetsNoRegion(t *testing.T) { |
| + key := []byte("0123456789abcdef0123456789abcdef") |
| + src := ReportEnv(key, map[string]string{"A": "1", "CARLOS_REGION": "uk"}) |
| + noRegion := ReportEnv(key, map[string]string{"A": "1"}) |
| + if diffs := CompareEnv(src, noRegion, false, true); len(diffs) != 0 { |
| + t.Fatalf("home target without a region: %v", diffs) |
| + } |
| + if diffs := CompareEnv(src, noRegion, false, false); len(diffs) != 1 || diffs[0].Name != RegionVar || diffs[0].Kind != "region-missing" { |
| + t.Fatalf("named target without a region: %v", diffs) |
| + } |
| + ie := ReportEnv(key, map[string]string{"A": "1", "CARLOS_REGION": "ie"}) |
| + if diffs := CompareEnv(src, ie, false, true); len(diffs) != 1 || diffs[0].Name != RegionVar || diffs[0].Kind != "region-present" { |
| + t.Fatalf("home target with a region: %v", diffs) |
| + } |
| + // the source's own region value on a home target: present, not same |
| + uk := ReportEnv(key, map[string]string{"A": "1", "CARLOS_REGION": "uk"}) |
| + if diffs := CompareEnv(src, uk, false, true); len(diffs) != 1 || diffs[0].Kind != "region-present" { |
| + t.Fatalf("home target with the source's region: %v", diffs) |
| + } |
| + // a source without a region (home to home, which the door refuses; |
| + // T3 does not rely on that): absent on both sides is clean, a |
| + // target-only region is still region-present |
| + srcHome := ReportEnv(key, map[string]string{"A": "1"}) |
| + if diffs := CompareEnv(srcHome, noRegion, false, true); len(diffs) != 0 { |
| + t.Fatalf("no region on either side, home target: %v", diffs) |
| + } |
| + if diffs := CompareEnv(srcHome, ie, false, true); len(diffs) != 1 || diffs[0].Name != RegionVar || diffs[0].Kind != "region-present" { |
| + t.Fatalf("target-only region, home target: %v", diffs) |
| + } |
| + // the rest of T3 is unchanged by targetHome |
| + drift := ReportEnv(key, map[string]string{"A": "2", "EXTRA": "x"}) |
| + kinds := map[string]string{} |
| + for _, d := range CompareEnv(src, drift, false, true) { |
| + kinds[d.Name] = d.Kind |
| + } |
| + if len(kinds) != 2 || kinds["A"] != "differs" || kinds["EXTRA"] != "extra" { |
| + t.Fatalf("home target drift: %v", kinds) |
| + } |
| + if (EnvDiff{RegionVar, "region-present"}).Note() { |
| + t.Fatal("region-present is a note") |
| + } |
| +} |
| + |
| +// A named target's T3 region rule is the one before home part 2, with |
| +// targetHome false at every call: the target must set its own region, |
| +// different from the source's; a target-only region (a move out of home) |
| +// is no diff. |
| +func TestNamedTargetT3Unchanged(t *testing.T) { |
| + key := []byte("0123456789abcdef0123456789abcdef") |
| + ie := map[string]string{"A": "1", "CARLOS_REGION": "ie"} |
| + uk := map[string]string{"A": "1", "CARLOS_REGION": "uk"} |
| + none := map[string]string{"A": "1"} |
| + for _, tc := range []struct { |
| + name string |
| + src, tgt map[string]string |
| + want string |
| + }{ |
| + {"named to named", ie, uk, ""}, |
| + {"same region", ie, ie, "region-same"}, |
| + {"target sets none", ie, none, "region-missing"}, |
| + {"out of home", none, uk, ""}, |
| + {"neither sets one", none, none, ""}, |
| + } { |
| + diffs := CompareEnv(ReportEnv(key, tc.src), ReportEnv(key, tc.tgt), false, false) |
| + got := "" |
| + if len(diffs) > 1 || (len(diffs) == 1 && diffs[0].Name != RegionVar) { |
| + t.Fatalf("%s: %v", tc.name, diffs) |
| + } |
| + if len(diffs) == 1 { |
| + got = diffs[0].Kind |
| + } |
| + if got != tc.want { |
| + t.Errorf("%s: %q, want %q", tc.name, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| func TestReportEnvIsDeterministicAndDigestCoversNamesAndValues(t *testing.T) { |
| key := []byte("0123456789abcdef0123456789abcdef") |
| a := ReportEnv(key, map[string]string{"A": "1", "B": "2"}) |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 2618e1b5a..df1a2e065 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -741,6 +741,13 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-hostfiles-cap-236.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| + // Census overlay home-part2 (amadan #236, moves into home part 2): |
| + // envelope-only, for T3's home rule (moves.CompareEnv's targetHome). |
| + // No root-read unit changed or was added, so the legacy seal stands. |
| + // 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) |
| + } |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/testdata/plan14-root-reads-home-part2.tsv b/internal/provision/testdata/plan14-root-reads-home-part2.tsv| new file mode 100644 |
| index 000000000..040587758 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-home-part2.tsv |
| @@ -0,0 +1,4 @@ |
| +# Plan 14 census overlay home-part2 (amadan #236, moves into home part 2, Task 1; loaded after hostfiles-cap-236). RULING OWED: the Plan 14 owner has not yet approved this overlay. |
| +# Envelope-only. moves.CompareEnv takes targetHome, and T3 (internal/host checkEnv) passes rec.Move.To == "" for it: a home target must set no CARLOS_REGION and fails region-present when it does. No root-read unit changed or was added, every disposition is main's, and the legacy seal stands. Earlier overlays are immutable. |
| +@coverage internal/host 602e8a76280ba4c2600ace7be42022b8c77faf071ef65b69dcba2444a5f1931d |
| +@coverage internal/moves 20c9b252450b673c3d3e53df0cbaeb9e4215edd40d88e99d630f77a5cd9ed6d9 |