carlos / platform Public

moves: the env pin hashes the move-portable env (home part 2, 2b)

The source pinned the digest of its whole composed environment, and
the probe, the committed wake and the recovery checked the target's
own composition against it. Each box sets its own agent-owned
variables (its region, or none on home; its socket; its purge
channel; the row's name), so every cross-region landing was refused.

internal/moves now defines the box-owned set once: AgentOwnedVars (the
variables agentOwnedVars sets, overwrites or removes) plus BoxVars,
BoxOwned. T3's Class tolerates exactly it (CARLOS_HOST joins the box
class), ReportEnv's Digest covers every other variable
(PortableDigest), and envDigest uses it, so the pin, the probe, the
committed wake and the recovery owners hash one move-portable env.
CARLOS_MOVE_PROBE stays stripped. An instance variable still changes
the pin.

The close harness composes both sides through the production composer
(realLaunchEnv) and the probe launches the target's own composition.
Witnesses: named to named and named to home commit through the
production probe (refused before), an instance env change is refused,
the recovery owners hash the portable env, and
TestAgentOwnedVarsAreMovesBoxOwned pins the set against
agentOwnedVars. Census: the home-part2 overlay's envelope hashes,
ruling owed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev f4f51ba660a523387c1f5a3afe9af36bbbefbc67 parent 385d3eb
11 files changed, +378 −46
  • internal/activator/agentowned_moves_test.go +69 −0
  • internal/host/movescut_test.go +4 −5
  • internal/host/movesenvpin_test.go +99 −0
  • internal/host/moveshome_fixture_test.go +2 −2
  • internal/host/movesparks_test.go +18 −5
  • internal/host/movesphase1_harness_test.go +37 −7
  • internal/host/movesprobeowners.go +12 −6
  • internal/moves/envreport.go +57 −14
  • internal/moves/envreport_test.go +73 −2
  • internal/provision/plan14_root_reads_test.go +2 −1
  • internal/provision/testdata/plan14-root-reads-home-part2.tsv +5 −4

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	281.887s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.631s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.213s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.702s
ok  	github.com/carlosframework/platform/internal/activator	249.219s
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.018s
ok  	github.com/carlosframework/platform/internal/appconfig	2.464s
ok  	github.com/carlosframework/platform/internal/apperr	0.077s
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.005s
ok  	github.com/carlosframework/platform/internal/boxid	0.980s
?   	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.007s
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.692s
ok  	github.com/carlosframework/platform/internal/changelog	0.309s
ok  	github.com/carlosframework/platform/internal/clamd	0.058s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	387.916s
?   	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	5.975s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.008s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.005s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.341s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.005s
ok  	github.com/carlosframework/platform/internal/edge	26.639s
?   	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.132s
ok  	github.com/carlosframework/platform/internal/gate	1.654s
ok  	github.com/carlosframework/platform/internal/genselect	0.015s
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	133.553s
ok  	github.com/carlosframework/platform/internal/hostactors	0.387s
ok  	github.com/carlosframework/platform/internal/hostbox	2.185s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.479s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.012s
ok  	github.com/carlosframework/platform/internal/hostexec	4.755s
ok  	github.com/carlosframework/platform/internal/hostfence	0.040s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.500s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.012s
ok  	github.com/carlosframework/platform/internal/hostguard	0.971s
ok  	github.com/carlosframework/platform/internal/hostops	1.529s
?   	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.430s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.134s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.015s
ok  	github.com/carlosframework/platform/internal/infraview	0.012s
ok  	github.com/carlosframework/platform/internal/instancectl	4.175s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.737s
ok  	github.com/carlosframework/platform/internal/instancefs	0.012s
ok  	github.com/carlosframework/platform/internal/instancekey	0.015s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.019s
ok  	github.com/carlosframework/platform/internal/layout	1.486s
ok  	github.com/carlosframework/platform/internal/lease	9.801s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.016s
ok  	github.com/carlosframework/platform/internal/ledger	0.011s
ok  	github.com/carlosframework/platform/internal/mail	0.162s
ok  	github.com/carlosframework/platform/internal/maintenance	0.077s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.496s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.065s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	20.939s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.750s
ok  	github.com/carlosframework/platform/internal/objstore	0.171s
ok  	github.com/carlosframework/platform/internal/ops	0.184s
ok  	github.com/carlosframework/platform/internal/park	0.207s
ok  	github.com/carlosframework/platform/internal/privatefile	0.004s
ok  	github.com/carlosframework/platform/internal/provision	15.557s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.009s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	4.893s
ok  	github.com/carlosframework/platform/internal/relay	7.230s
ok  	github.com/carlosframework/platform/internal/release	20.159s
ok  	github.com/carlosframework/platform/internal/rollout	3.685s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
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.025s
ok  	github.com/carlosframework/platform/internal/runs	0.565s
ok  	github.com/carlosframework/platform/internal/s3compat	0.004s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.460s
ok  	github.com/carlosframework/platform/internal/seeds	0.010s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.075s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.836s
ok  	github.com/carlosframework/platform/internal/status	0.036s
ok  	github.com/carlosframework/platform/internal/steering	0.005s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.219s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.002s
ok  	github.com/carlosframework/platform/internal/updatewords	0.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.526s
=== 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.022s
=== RUN   TestT4StorageProbeMinio
2026/10/15 16:45:52 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEC1C4DA3651D5, 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.020s
minio contract: PASS
diff --git a/internal/activator/agentowned_moves_test.go b/internal/activator/agentowned_moves_test.go
new file mode 100644
index 000000000..92afa9410
--- /dev/null
+++ b/internal/activator/agentowned_moves_test.go
@@ -0,0 +1,69 @@
+package activator
+
+import (
+ "maps"
+ "slices"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/edge"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// TestAgentOwnedVarsAreMovesBoxOwned pins moves.AgentOwnedVars against
+// agentOwnedVars (home part 2, Task 2b). A move's env pin and T3 leave out
+// exactly the box-owned variables, because each box composes them for
+// itself; a variable the agent starts to set, overwrite or remove without
+// joining that set would make every cross-region move's pin differ on the
+// target again. Every variable agentOwnedVars sets on an empty file env,
+// with every feature on, and every tenant-provided variable it overwrites or
+// removes with every feature off, is in moves.AgentOwnedVars, and nothing
+// else is.
+func TestAgentOwnedVarsAreMovesBoxOwned(t *testing.T) {
+ rt := registry.Route{Host: "shop.example.com", Socket: "/run/carlos/shop.example.com.sock"}
+ touched := map[string]bool{}
+
+ on := &ExecBackend{Region: "uk", Cache: edge.NewCache(16, 1<<20)}
+ for k := range on.agentOwnedVars(rt, rt.Socket+".control", map[string]string{}, true) {
+ touched[k] = true
+ }
+
+ // A tenant's env file naming every CARLOS_* variable the agent might
+ // own, and one it does not: what the agent overwrites or removes is
+ // its own.
+ tenant := map[string]string{"APP_MODE": "tenant"}
+ for k := range moves.AgentOwnedVars {
+ tenant[k] = "tenant"
+ }
+ for k := range touched {
+ tenant[k] = "tenant"
+ }
+ for _, b := range []*ExecBackend{{}, on} {
+ for _, control := range []bool{false, true} {
+ got := b.agentOwnedVars(rt, rt.Socket+".control", tenant, control)
+ for k, v := range tenant {
+ if got[k] != v {
+ touched[k] = true
+ }
+ }
+ for k := range got {
+ if _, ok := tenant[k]; !ok {
+ touched[k] = true
+ }
+ }
+ }
+ }
+ got := slices.Sorted(maps.Keys(touched))
+ want := slices.Sorted(maps.Keys(moves.AgentOwnedVars))
+ if !slices.Equal(got, want) {
+ t.Fatalf("agentOwnedVars owns %v, moves.AgentOwnedVars says %v", got, want)
+ }
+ for _, k := range got {
+ if !moves.BoxOwned(k) || moves.Class(k) == "equal" {
+ t.Errorf("%s is agent-owned but not box-owned (class %q)", k, moves.Class(k))
+ }
+ }
+ if touched["APP_MODE"] {
+ t.Fatal("a tenant's own variable was treated as agent-owned")
+ }
+}
diff --git a/internal/host/movescut_test.go b/internal/host/movescut_test.go
index 5a3302b2d..3b313a18e 100644
--- a/internal/host/movescut_test.go
+++ b/internal/host/movescut_test.go
@@ -149,12 +149,11 @@ func newCutHarness(t *testing.T, opts ...p1Option) *cutHarness {
// targetCfg composes with the harness's current bundle versions.
func (h *cutHarness) target() moveCfg {
c := h.targetCfg()
- c.ComposeHeldEnv = func(context.Context, release.Store, provision.Record, []byte) (map[string]string, []string, provision.MoveInputs, error) {
- env := map[string]string{}
- for k, v := range h.env {
- env[k] = v
+ c.ComposeHeldEnv = func(ctx context.Context, s release.Store, rec provision.Record, carried []byte) (map[string]string, []string, provision.MoveInputs, error) {
+ env, err := h.heldEnv(ctx, s, rec, carried)
+ if err != nil {
+ return nil, nil, provision.MoveInputs{}, err
}
- setHeldRegion(env, h.dst)
b := map[string]string{}
for k, v := range h.bundle {
b[k] = v
diff --git a/internal/host/movesenvpin_test.go b/internal/host/movesenvpin_test.go
new file mode 100644
index 000000000..dc1116213
--- /dev/null
+++ b/internal/host/movesenvpin_test.go
@@ -0,0 +1,99 @@
+package host
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// The move's env pin (home part 2, Task 2b). The source pins the report
+// digest of the environment its ExecBackend would launch (sourceReport,
+// copied into the target's report as Inputs.EnvReport); the probe and the
+// committed wake on the target check the digest of the target's own
+// composed launch (claimMoveLaunchSource). Each box owns some variables of
+// that environment (agentOwnedVars: its region, its control socket, its
+// purge channel, the host row's name), so the two compositions differ in
+// those whatever the instance carries. Both sides hash the move-portable
+// env: the composition without the box-owned variables (moves.BoxOwned).
+
+// TestProbeEnvPinIsMovePortable: a landing whose source and target compose
+// their environments through the production composer, each with its own
+// region, passes the production probe and commits, named to named and
+// named to home.
+func TestProbeEnvPinIsMovePortable(t *testing.T) {
+ for name, opts := range map[string][]p1Option{
+ "named to named": {realLaunchEnv},
+ "named to home": {intoHome, realLaunchEnv},
+ } {
+ t.Run(name, func(t *testing.T) {
+ h := newCloseHarness(t, opts...)
+ p := h.productionProbe(t)
+ h.land(t)
+ if m := h.read(t, p1Host).Move; m.Reason != "" {
+ t.Fatalf("the landing stopped for a rollback: %q", m.Reason)
+ }
+ if len(p.admissions) != 1 || p.admissions[0].Box != h.dstBox {
+ t.Fatalf("the probe's admissions: %+v", p.admissions)
+ }
+ h.committedAndOpen(t)
+ })
+ }
+}
+
+// TestProbeEnvPinRefusesAnInstanceChange: the pin still binds the
+// instance's own variables. An app variable that differs on the target
+// (its installed .env changed after the pin) refuses the probe, and the
+// landing stops for the configuration-changed rollback.
+func TestProbeEnvPinRefusesAnInstanceChange(t *testing.T) {
+ for name, opts := range map[string][]p1Option{
+ "named to named": {realLaunchEnv},
+ "named to home": {intoHome, realLaunchEnv},
+ } {
+ t.Run(name, func(t *testing.T) {
+ h := newCloseHarness(t, opts...)
+ p := h.productionProbe(t)
+ if err := os.WriteFile(filepath.Join(h.tdir, p1Host+".env"), []byte("APP_MODE=staging\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ h.land(t)
+ if len(p.admissions) != 0 {
+ t.Fatalf("a changed instance env admitted: %+v", p.admissions)
+ }
+ if m := h.read(t, p1Host).Move; m.Reason != provision.RollbackConfigChanged || m.State == provision.MoveCommitted {
+ t.Fatalf("the landing after a changed instance env: state %s reason %q", m.State, m.Reason)
+ }
+ })
+ }
+}
+
+// TestRecoveryEnvCheckIsMovePortable: the recovery owners judge the
+// recovering box's launch against the recorded recovery inputs with the
+// same move-portable digest (envDigest). In production both sides are
+// composed on the recovering box (the inputs are its currentInputs,
+// recorded just before the wake: movesrecoveryprobe.go), so their box-owned
+// variables agree there; this pins that the recovery owners hash the shared
+// portable env, so inputs that differ only in box-owned variables are the
+// same inputs, and an instance variable that differs is still refused.
+func TestRecoveryEnvCheckIsMovePortable(t *testing.T) {
+ recorded := []string{"A=1", "CARLOS_REGION=uk", "CARLOS_CONTROL_SOCKET=/run/carlos/uk.sock.control", "CARLOS_HOST=" + poHost, "CARLOS_PURGE=header"}
+ launched := []string{"A=1", "CARLOS_REGION=ie", "CARLOS_CONTROL_SOCKET=/run/carlos/ie.sock.control", "CARLOS_HOST=" + poHost, "CARLOS_MOVE_PROBE=0"}
+ for _, kind := range []string{rcClean, rcDirty, rcRevert} {
+ t.Run(kind, func(t *testing.T) {
+ f := newRCFixture(t, kind)
+ f.launch.Env = launched
+ f.inputs.EnvReport = envDigest(poEnvKey, recorded)
+ if _, err := f.owners.Select(t.Context(), f.route, f.read(), f.policy()); err != nil {
+ t.Fatalf("inputs that differ only in box-owned variables: %v", err)
+ }
+ f.launch.Env = append([]string{"A=2"}, launched[1:]...)
+ if _, err := f.owners.Select(t.Context(), f.route, f.read(), f.policy()); !errors.Is(err, hostbox.ErrChanged) || !strings.Contains(err.Error(), "launch environment") {
+ t.Fatalf("an instance variable that differs: %v", err)
+ }
+ })
+ }
+}
diff --git a/internal/host/moveshome_fixture_test.go b/internal/host/moveshome_fixture_test.go
index be53b067c..11affa0aa 100644
--- a/internal/host/moveshome_fixture_test.go
+++ b/internal/host/moveshome_fixture_test.go
@@ -117,7 +117,7 @@ func TestIntoHomeFixtureIsAHomeTarget(t *testing.T) {
})
t.Run("the production probe admits on home-box in ie", func(t *testing.T) {
- h := newCloseHarness(t, intoHome)
+ h := newCloseHarness(t, intoHome, realLaunchEnv)
if m := h.read(t, p1Host).Move; m.To != "" || m.State != provision.MoveLanding || m.LandingPhase != provision.LandRestored {
t.Fatalf("the close harness: %+v", m)
}
@@ -150,7 +150,7 @@ func TestIntoHomeFixtureIsAHomeTarget(t *testing.T) {
})
t.Run("rejected admission evidence admits nothing", func(t *testing.T) {
- h := newCloseHarness(t, intoHome)
+ h := newCloseHarness(t, intoHome, realLaunchEnv)
p := h.productionProbe(t)
before := h.claim(t).Holder
p.rejectEvidence = hostbox.ErrChanged
diff --git a/internal/host/movesparks_test.go b/internal/host/movesparks_test.go
index d35169b51..492995249 100644
--- a/internal/host/movesparks_test.go
+++ b/internal/host/movesparks_test.go
@@ -556,6 +556,11 @@ type prodProbe struct {
// the target box's roster identity.
func (h *landingHarness) productionProbe(t *testing.T) *prodProbe {
t.Helper()
+ if !h.realEnv {
+ // The probe's launch is the target's own composition; a source that
+ // pinned the h.env fixture would never match it.
+ t.Fatal("fixture: the production probe mode needs realLaunchEnv")
+ }
// Claim Plan 15: admissions through the real CAS record their origin
// under both switches; the real C8's proof reads that record.
t.Setenv("CARLOS_HOST_CLAIM", "1")
@@ -580,11 +585,19 @@ func (h *landingHarness) productionProbe(t *testing.T) *prodProbe {
ReadyPath: movesReadyPath(h.s), Data: h.vs, Bucket: h.dstBucket, Region: actor.Region, Box: actor.Box, DataDir: h.tdir, FilesDir: filesDir,
Markers: p1Markers{h.vs},
Route: func(host string) (*registry.Route, error) { rt := h.route; return &rt, nil },
- // The held row's launch: the environment the readiness pin was
- // reported over (the source's launch report), the live release.
- Launch: func(context.Context, registry.Route) (ClaimServingLaunch, error) {
- env := make([]string, 0, len(h.env))
- for k, v := range h.env {
+ // The held row's launch: what this box would launch, composed by
+ // the production composer over the target's data directory with
+ // the target's own region, as claimMoveLaunchSource composes it
+ // (ExecBackend.LaunchEnvironment, CARLOS_MOVE_PROBE dropped), and
+ // the live release.
+ Launch: func(_ context.Context, rt registry.Route) (ClaimServingLaunch, error) {
+ composed, _, err := (&activator.ExecBackend{DataDir: h.tdir, Region: h.dst}).LaunchEnvironment(rt)
+ if err != nil {
+ return ClaimServingLaunch{}, err
+ }
+ delete(composed, "CARLOS_MOVE_PROBE")
+ env := make([]string, 0, len(composed))
+ for k, v := range composed {
env = append(env, k+"="+v)
}
sort.Strings(env)
diff --git a/internal/host/movesphase1_harness_test.go b/internal/host/movesphase1_harness_test.go
index 652461398..cf66175fd 100644
--- a/internal/host/movesphase1_harness_test.go
+++ b/internal/host/movesphase1_harness_test.go
@@ -15,6 +15,7 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostclaim"
@@ -381,6 +382,35 @@ type phase1Harness struct {
// retain is the claim state seeded at start as an earlier stay left it
// (retainTouched, retainQuiet, retainChore).
retain p1Retained
+ // realEnv composes both sides' environments through the production
+ // composer (realLaunchEnv): the source's launch report is its
+ // ExecBackend's LaunchEnvironment, the target's held env composeHeldEnv
+ // with the target's own region, each box's agent-owned variables its
+ // own.
+ realEnv bool
+}
+
+// realLaunchEnv composes both boxes' environments through the production
+// composer instead of the h.env fixture (home part 2, Task 2b): the source
+// pins what its ExecBackend would launch, and the target composes its own,
+// so the box-owned variables differ between the two exactly as they do in
+// production.
+func realLaunchEnv(h *phase1Harness) { h.realEnv = true }
+
+// heldEnv is the target's held environment for rec: the h.env fixture with
+// the target's region rule, or under realLaunchEnv the production
+// composition over the carried env with the target's region.
+func (h *phase1Harness) heldEnv(ctx context.Context, s release.Store, rec provision.Record, carried []byte) (map[string]string, error) {
+ if h.realEnv {
+ env, _, _, err := composeHeldEnv(h.cfg.SecretsPriv, h.dst, false)(ctx, s, rec, carried)
+ return env, err
+ }
+ env := map[string]string{}
+ for k, v := range h.env {
+ env[k] = v
+ }
+ setHeldRegion(env, h.dst)
+ return env, nil
}
// p1Roster is the fixture's signed roster: each box's actor, as
@@ -515,6 +545,10 @@ func newPhase1Harness(t *testing.T, opts ...p1Option) *phase1Harness {
h.cfg.Versioned = func(context.Context, string, string) (lease.VersionedStore, error) { return h.vs, nil }
h.cfg.SecretsKey, h.cfg.SecretsPriv, h.cfg.SecretsPub = privB64, priv, priv.PublicKey()
h.cfg.LaunchEnv = func(string) (map[string]string, []string, error) { return h.env, nil, nil }
+ if h.realEnv {
+ 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) }
+ }
h.cfg.FilesDir = func(host string) (string, string) {
return filepath.Join(h.cfg.DataDir, host), filepath.Join(h.cfg.DataDir, host+".env")
}
@@ -699,13 +733,9 @@ func (h *phase1Harness) targetCfg() moveCfg {
}
c.OwnsRoute = func(string) bool { return false }
c.Route = func(string) (*registry.Route, error) { return nil, nil }
- c.ComposeHeldEnv = func(context.Context, release.Store, provision.Record, []byte) (map[string]string, []string, provision.MoveInputs, error) {
- env := map[string]string{}
- for k, v := range h.env {
- env[k] = v
- }
- setHeldRegion(env, h.dst)
- return env, nil, provision.MoveInputs{BundleETags: map[string]string{"default": "e1", "environment": ""}}, nil
+ c.ComposeHeldEnv = func(ctx context.Context, s release.Store, rec provision.Record, carried []byte) (map[string]string, []string, provision.MoveInputs, error) {
+ env, err := h.heldEnv(ctx, s, rec, carried)
+ return env, nil, provision.MoveInputs{BundleETags: map[string]string{"default": "e1", "environment": ""}}, err
}
c.ProbeStore = func(context.Context, map[string]string, string) (string, error) { return "", nil }
c.DiskFree = func(string) (uint64, uint64, error) { return 100 << 30, 90 << 30, nil }
diff --git a/internal/host/movesprobeowners.go b/internal/host/movesprobeowners.go
index ebccad647..187ce139a 100644
--- a/internal/host/movesprobeowners.go
+++ b/internal/host/movesprobeowners.go
@@ -121,10 +121,11 @@ type movesProbeOwners struct {
// launch's K=V list, last value wins (childEnv's dedupe), with the
// probe-only variables (probeOnlyVars: CARLOS_MOVE_PROBE) removed,
// since the source never ran with them and the probe adds
- // CARLOS_MOVE_PROBE=1 after the pin. The production Launch must
- // therefore be the env the readiness pin was reported over
- // (ExecBackend.LaunchEnvironment's composition; claim Plan 10's launch
- // accessor).
+ // CARLOS_MOVE_PROBE=1 after the pin, and the digest is the
+ // move-portable one (moves.PortableDigest): the box-owned variables,
+ // which each box composes for itself, are left out on both sides. The
+ // production Launch is this box's own composition
+ // (ExecBackend.LaunchEnvironment; claim Plan 10's launch accessor).
EnvKey []byte
// Admitted is the number of probe attempts T11 admitted (H10).
Admitted func(context.Context, *provision.Record) (int, error)
@@ -332,7 +333,12 @@ func (m *movesProbeOwners) admissible(ctx context.Context, v probeLanding) error
var probeOnlyVars = map[string]bool{"CARLOS_MOVE_PROBE": true}
// envDigest is the env report digest of a launch environment, normalised
-// as EnvKey's comment states.
+// as EnvKey's comment states: the probe-only variables dropped, and the
+// digest the move-portable one (moves.PortableDigest, every variable that
+// is not moves.BoxOwned), as the source's pin is. The target's own
+// composition sets its own box-owned variables (its region, or none on a
+// home box; its socket; its purge channel), so a digest over them could
+// never match a pin made on another box (home part 2, Task 2b).
func envDigest(key []byte, env []string) string {
vars := map[string]string{}
for _, kv := range env {
@@ -342,7 +348,7 @@ func envDigest(key []byte, env []string) string {
}
vars[k] = v
}
- return moves.ReportEnv(key, vars).Digest
+ return moves.PortableDigest(key, vars)
}
// manifestDigest is install.json's digest: the build writes the manifest
diff --git a/internal/moves/envreport.go b/internal/moves/envreport.go
index 30424aaa9..0605c3c0f 100644
--- a/internal/moves/envreport.go
+++ b/internal/moves/envreport.go
@@ -36,8 +36,26 @@ var (
// (SYSTEMD_EXEC_PID, MEMORY_PRESSURE_WATCH); the rehearsal checks the
// real box.
BoxVars = map[string]bool{"INVOCATION_ID": true, "JOURNAL_STREAM": true, "HOSTNAME": true, "PWD": true, "OLDPWD": true, "SHLVL": true, "_": true}
+ // AgentOwnedVars are the variables the agent sets, overwrites or
+ // removes on every launch (activator ExecBackend.agentOwnedVars): the
+ // control socket, the purge channel, the box's region and the host
+ // row's name. A tenant's env file never decides them, and each box sets
+ // its own, so they are box-owned (BoxOwned).
+ // TestAgentOwnedVarsAreMovesBoxOwned (internal/activator) pins this set
+ // against agentOwnedVars.
+ AgentOwnedVars = map[string]bool{RegionVar: true, "CARLOS_CONTROL_SOCKET": true, "CARLOS_PURGE": true, "CARLOS_HOST": true}
)
+// BoxOwned reports a variable whose value is the box's, not the
+// instance's: an agent-owned variable or a box variable of the box's own
+// process. It is the one set every comparison of a move's environment
+// across two boxes leaves out (home part 2, Task 2b): T3 tolerates exactly
+// these (Class is not "equal"), and the env report's digest, which is the
+// move's env pin on the source and what the probe, the committed wake and
+// the recovery check on the target, covers every other variable (the
+// move-portable env).
+func BoxOwned(name string) bool { return AgentOwnedVars[name] || BoxVars[name] }
+
// ReportKeyFromSecrets derives the report's HMAC key from
// CARLOS_SECRETS_KEY (HKDF-SHA256), so the report reveals names and
// equality only and only to boxes holding the deployment's key (§7.3).
@@ -64,29 +82,54 @@ type EnvReport struct {
Digest string `json:"digest"`
}
-// ReportEnv HMACs every value (bound to its name); Digest identifies the
-// whole report. Raw values never appear in the result.
+// ReportEnv HMACs every value (bound to its name). Vars carries every
+// variable, for T3's comparison by name; Digest identifies the report's
+// move-portable env, every variable that is not BoxOwned, so the source's
+// pin and the target's composition of the same instance hash the same
+// (PortableDigest). Raw values never appear in the result.
func ReportEnv(key []byte, env map[string]string) EnvReport {
r := EnvReport{V: 1, Vars: make(map[string]string, len(env))}
- names := make([]string, 0, len(env))
for k, v := range env {
- mac := hmac.New(sha256.New, key)
- mac.Write([]byte(k))
- mac.Write([]byte{0})
- mac.Write([]byte(v))
- r.Vars[k] = hex.EncodeToString(mac.Sum(nil))
- names = append(names, k)
+ r.Vars[k] = macVar(key, k, v)
+ }
+ r.Digest = portableDigest(r.Vars)
+ return r
+}
+
+// PortableDigest is the digest of env's move-portable part: ReportEnv's
+// Digest, the move's env pin.
+func PortableDigest(key []byte, env map[string]string) string {
+ return ReportEnv(key, env).Digest
+}
+
+func macVar(key []byte, name, value string) string {
+ mac := hmac.New(sha256.New, key)
+ mac.Write([]byte(name))
+ mac.Write([]byte{0})
+ mac.Write([]byte(value))
+ return hex.EncodeToString(mac.Sum(nil))
+}
+
+// portableDigest hashes the reported variables that are not BoxOwned.
+func portableDigest(vars map[string]string) string {
+ names := make([]string, 0, len(vars))
+ for k := range vars {
+ if !BoxOwned(k) {
+ names = append(names, k)
+ }
}
sort.Strings(names)
h := sha256.New()
for _, k := range names {
- fmt.Fprintf(h, "%s=%s\n", k, r.Vars[k])
+ fmt.Fprintf(h, "%s=%s\n", k, vars[k])
}
- r.Digest = "sha256:" + hex.EncodeToString(h.Sum(nil))
- return r
+ return "sha256:" + hex.EncodeToString(h.Sum(nil))
}
-// Class is T3's variable class.
+// Class is T3's variable class. Every BoxOwned variable has a class other
+// than "equal", and only those do: T3 tolerates exactly the box-owned set
+// the env pin leaves out. The agent-owned variables without a rule of
+// their own (CARLOS_HOST, the row's name) are box variables.
func Class(name string) string {
switch {
case name == RegionVar:
@@ -95,7 +138,7 @@ func Class(name string) string {
return "layout"
case AdvisoryVars[name]:
return "advisory"
- case BoxVars[name]:
+ case BoxOwned(name):
return "box"
default:
return "equal"
diff --git a/internal/moves/envreport_test.go b/internal/moves/envreport_test.go
index 18cb17434..ccb031379 100644
--- a/internal/moves/envreport_test.go
+++ b/internal/moves/envreport_test.go
@@ -51,8 +51,79 @@ func TestEnvReportRevealsNamesAndEqualityOnly(t *testing.T) {
if ReportEnv([]byte("another-key-another-key-another!"), map[string]string{"SECRET": "s3"}).Vars["SECRET"] == src.Vars["SECRET"] {
t.Fatal("the mac does not depend on the key")
}
- if src.Digest == "" || src.Digest == same.Digest {
- t.Fatal("digest")
+ // The digest is the move-portable one (home part 2, Task 2b): src and
+ // same differ only in box-owned variables (region, control socket, a
+ // box variable), so they pin the same; drift differs in the instance's
+ // own variables, so it does not.
+ if src.Digest == "" || src.Digest != same.Digest || src.Digest == drift.Digest {
+ t.Fatalf("digest: src %s same %s drift %s", src.Digest, same.Digest, drift.Digest)
+ }
+}
+
+// TestBoxOwnedIsTheOneSharedSet: the box-owned set is the agent-owned
+// variables and the box variables, T3 tolerates exactly it (every
+// box-owned name has a class other than "equal", every other name is
+// "equal"), and the layout, advisory and region classes are inside it.
+func TestBoxOwnedIsTheOneSharedSet(t *testing.T) {
+ for _, set := range []map[string]bool{AgentOwnedVars, BoxVars, LayoutVars, AdvisoryVars, {RegionVar: true}} {
+ for name := range set {
+ if !BoxOwned(name) || Class(name) == "equal" {
+ t.Errorf("%s: box-owned %v, class %q", name, BoxOwned(name), Class(name))
+ }
+ }
+ }
+ for _, name := range []string{"CARLOS_REGION", "CARLOS_CONTROL_SOCKET", "CARLOS_PURGE", "CARLOS_HOST"} {
+ if !AgentOwnedVars[name] {
+ t.Errorf("%s is not agent-owned", name)
+ }
+ }
+ if Class("CARLOS_HOST") != "box" {
+ t.Errorf("CARLOS_HOST is class %q, want box", Class("CARLOS_HOST"))
+ }
+ for _, name := range []string{"DATABASE_URL", "CARLOS_ADMIN_TOKEN", "CARLOS_INSTANCE_KEY", "APP_MODE", "PATH", "HOME"} {
+ if BoxOwned(name) || Class(name) != "equal" {
+ t.Errorf("%s: box-owned %v, class %q", name, BoxOwned(name), Class(name))
+ }
+ }
+}
+
+// TestPortableDigestCoversTheInstanceOnly: the pin ignores every box-owned
+// variable, present, absent or different, and still binds every instance
+// variable: its value, its presence, its name.
+func TestPortableDigestCoversTheInstanceOnly(t *testing.T) {
+ key := []byte("0123456789abcdef0123456789abcdef")
+ base := map[string]string{"APP_MODE": "prod", "DATABASE_URL": "sqlite:a"}
+ pin := PortableDigest(key, base)
+ if pin != ReportEnv(key, base).Digest {
+ t.Fatal("PortableDigest is not ReportEnv's digest")
+ }
+ with := func(edit func(map[string]string)) string {
+ env := map[string]string{}
+ for k, v := range base {
+ env[k] = v
+ }
+ edit(env)
+ return PortableDigest(key, env)
+ }
+ for name := range AgentOwnedVars {
+ if got := with(func(e map[string]string) { e[name] = "box-a" }); got != pin {
+ t.Errorf("%s present changed the pin", name)
+ }
+ }
+ for name := range BoxVars {
+ if got := with(func(e map[string]string) { e[name] = "box-b" }); got != pin {
+ t.Errorf("%s present changed the pin", name)
+ }
+ }
+ for name, edit := range map[string]func(map[string]string){
+ "a value": func(e map[string]string) { e["APP_MODE"] = "staging" },
+ "an extra": func(e map[string]string) { e["EXTRA"] = "1" },
+ "a missing": func(e map[string]string) { delete(e, "DATABASE_URL") },
+ "a rename": func(e map[string]string) { e["APP_MODE2"] = e["APP_MODE"]; delete(e, "APP_MODE") },
+ } {
+ if with(edit) == pin {
+ t.Errorf("%s instance variable left the pin unchanged", name)
+ }
}
}
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index df1a2e065..26aac696e 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -742,7 +742,8 @@ func TestPlan14RootReadCensus(t *testing.T) {
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).
+ // envelope-only, for T3's home rule (moves.CompareEnv's targetHome)
+ // and the move-portable env pin (moves.BoxOwned, PortableDigest).
// 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 {
diff --git a/internal/provision/testdata/plan14-root-reads-home-part2.tsv b/internal/provision/testdata/plan14-root-reads-home-part2.tsv
index 040587758..19381916a 100644
--- a/internal/provision/testdata/plan14-root-reads-home-part2.tsv
+++ b/internal/provision/testdata/plan14-root-reads-home-part2.tsv
@@ -1,4 +1,5 @@
-# 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
+# Plan 14 census overlay home-part2 (amadan #236, moves into home part 2, Tasks 1 and 2b; loaded after hostfiles-cap-236). RULING OWED: the Plan 14 owner has not yet approved this overlay.
+# Envelope-only. Task 1: 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. Task 2b: internal/moves defines the box-owned set once (AgentOwnedVars, the variables activator agentOwnedVars sets, overwrites or removes, plus BoxVars; BoxOwned). T3's Class tolerates exactly it, ReportEnv's Digest covers every variable outside it (PortableDigest, the move-portable env), and internal/host envDigest uses it, so the source's env pin and the target's checks hash the same portable env. No root-read unit changed or was added (Select, Materialize, the recovery owners and sourceReport read nothing new), every disposition is main's, and the legacy seal stands. Earlier overlays are immutable.
+# Env digests, by where they are compared. Across boxes, now the move-portable env: the source pin sourceReport sr.Env.Digest (host/movesphase1.go:875) copied to the target report's Inputs.EnvReport (host/movesreport.go:276) and checked on the target by the probe owners' Select and Materialize (host/movesprobeowners.go:620 and :802) and by the committed wake (committedLaunchIdentity against pinnedLaunchIdentity, :1430 and :1521); the cut's currentInputs (host/movescut.go:382), compared on the source with that pin (:389). Same box, recorded and checked on the recovering box, portable because it shares currentInputs with the pin: the recovery inputs (host/movesrecoveryprobe.go:139-146, w.currentInputs on that box) checked by the recovery owners (host/movesrecoveryowners.go:387 and :506). Same box, full composed env kept: snapshot.Identity.Config (host/claimmoveaccess.go:191), discarded by movesReadLaunch (host/moveswakesource.go:207) and never read by the moves owners; ClaimLaunchDigest (host/claimoperations.go:70, host/claimowners.go:400), compared only with the attempt's own box, boot and controller (host/movesprobeattempts.go:312 and :353, host/relocationattempts.go:265, host/claimlaunch_authority.go:89-96, host/claimwakestop.go:162, activator/servingretry.go:188 and :222, activator/rolelaunch_linux.go:37 and :135, activator/rolelaunch_root_linux.go:41).
+@coverage internal/host e90500b5f15a4710ab701d0d07afd4a7c5896faba1b81eaa902271125cc11d3b
+@coverage internal/moves 6aa0d97a15bcc9d6c03682b3372bf14c0e3d67acd0854dca40d29b0668ae3cc2