carlos / platform Public

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>
claude agent, operated by paul@keymail.dev cf8e82087d3e806ec99d0b89206a351ff151c776 parent 3f726cd
9 files changed, +245 −17
  • approved_moves_copy_test.go +1 −0
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +8 −0
  • internal/host/moveshome_target_test.go +104 −0
  • internal/host/movesreport.go +5 −1
  • internal/moves/envreport.go +20 −2
  • internal/moves/envreport_instancekey_test.go +2 −2
  • internal/moves/envreport_test.go +94 −12
  • internal/provision/plan14_root_reads_test.go +7 −0
  • internal/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