carlos / platform Public

moves: env report and pin version 2, fail closed on v1 (home part 2, 2b r3)

A v1 env report has no instance layer names, so an absent Instance
cannot tell an older producer, which never pinned a box variable, from
a newer one with none instance-set. The report is now version 2
(moves.EnvReportVersion) and its digest a v2 pin (env2:sha256:...).
T3 refuses a v1 source report before comparing (MV-N513, pending), and
the probe owners refuse a v1 pin in Select, Materialize and the
committed wake's checks, through their existing refusals. No v1 digest
can equal a v2 pin, so an older target refuses a newer pin too. This
is the report's version; moves.ProtocolVersion is unchanged (R5).

Witnesses, named to named and named to home: a v1 source report is
refused at T3; a v1 pin with the instance's HOSTNAME removed after the
pin (the target inheriting another) is refused at the probe; a v1
target's digest never matches a v2 pin, whether the target adds or
loses an instance HOSTNAME. Census: the home-part2 overlay's rows
re-frozen again at main's dispositions, new seal, ruling owed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e615c8d38effdead8080552cf973ca6fa10f9df1 parent a0d787e
10 files changed, +278 −41
  • approved_moves_copy_test.go +1 −1
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +6 −0
  • internal/host/claimmoveaccess_test.go +2 −2
  • internal/host/movesenvskew_test.go +165 −0
  • internal/host/movesprobeowners.go +6 −4
  • internal/host/movesreport.go +28 −19
  • internal/moves/envreport.go +29 −5
  • internal/moves/envreport_test.go +31 −1
  • internal/provision/plan14_root_reads_test.go +1 −1
  • internal/provision/testdata/plan14-root-reads-home-part2.tsv +9 −8

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.014s
?   	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	306.947s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.705s
ok  	github.com/carlosframework/platform/cmd/moveprobe	93.765s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.824s
ok  	github.com/carlosframework/platform/internal/activator	249.071s
ok  	github.com/carlosframework/platform/internal/agentsock	0.025s
ok  	github.com/carlosframework/platform/internal/alerting	0.016s
ok  	github.com/carlosframework/platform/internal/analytics	0.041s
ok  	github.com/carlosframework/platform/internal/appconfig	2.828s
ok  	github.com/carlosframework/platform/internal/apperr	0.116s
ok  	github.com/carlosframework/platform/internal/applog	0.189s
ok  	github.com/carlosframework/platform/internal/awsinv	0.015s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.007s
ok  	github.com/carlosframework/platform/internal/boxid	0.958s
?   	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.011s
ok  	github.com/carlosframework/platform/internal/caps	0.015s
ok  	github.com/carlosframework/platform/internal/certwarm	0.020s
ok  	github.com/carlosframework/platform/internal/cgroup	0.839s
ok  	github.com/carlosframework/platform/internal/changelog	0.387s
ok  	github.com/carlosframework/platform/internal/clamd	0.057s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	398.710s
?   	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.818s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.007s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.003s
ok  	github.com/carlosframework/platform/internal/docsite	0.007s
ok  	github.com/carlosframework/platform/internal/domains	2.525s
ok  	github.com/carlosframework/platform/internal/doorkey	0.009s
ok  	github.com/carlosframework/platform/internal/economics	0.007s
ok  	github.com/carlosframework/platform/internal/edge	26.916s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.009s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.133s
ok  	github.com/carlosframework/platform/internal/gate	1.657s
ok  	github.com/carlosframework/platform/internal/genselect	0.027s
ok  	github.com/carlosframework/platform/internal/geo	0.013s
ok  	github.com/carlosframework/platform/internal/geoip	0.030s
ok  	github.com/carlosframework/platform/internal/host	140.060s
ok  	github.com/carlosframework/platform/internal/hostactors	0.549s
ok  	github.com/carlosframework/platform/internal/hostbox	2.348s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.627s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.007s
ok  	github.com/carlosframework/platform/internal/hostexec	5.122s
ok  	github.com/carlosframework/platform/internal/hostfence	0.047s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.474s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.012s
ok  	github.com/carlosframework/platform/internal/hostguard	0.988s
ok  	github.com/carlosframework/platform/internal/hostops	1.741s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.003s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.002s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.559s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.157s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.004s
ok  	github.com/carlosframework/platform/internal/instancectl	4.174s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.725s
ok  	github.com/carlosframework/platform/internal/instancefs	0.016s
ok  	github.com/carlosframework/platform/internal/instancekey	0.003s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.017s
ok  	github.com/carlosframework/platform/internal/layout	1.592s
ok  	github.com/carlosframework/platform/internal/lease	9.819s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.006s
ok  	github.com/carlosframework/platform/internal/ledger	0.014s
ok  	github.com/carlosframework/platform/internal/mail	0.224s
ok  	github.com/carlosframework/platform/internal/maintenance	0.092s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.533s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.095s
ok  	github.com/carlosframework/platform/internal/metrics	0.006s
ok  	github.com/carlosframework/platform/internal/moves	21.413s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.849s
ok  	github.com/carlosframework/platform/internal/objstore	0.202s
ok  	github.com/carlosframework/platform/internal/ops	0.213s
ok  	github.com/carlosframework/platform/internal/park	0.264s
ok  	github.com/carlosframework/platform/internal/privatefile	0.004s
ok  	github.com/carlosframework/platform/internal/provision	16.180s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.007s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	4.990s
ok  	github.com/carlosframework/platform/internal/relay	4.238s
ok  	github.com/carlosframework/platform/internal/release	20.395s
ok  	github.com/carlosframework/platform/internal/rollout	3.723s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.005s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.008s
ok  	github.com/carlosframework/platform/internal/rootstate	0.029s
ok  	github.com/carlosframework/platform/internal/runs	0.598s
ok  	github.com/carlosframework/platform/internal/s3compat	0.009s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.557s
ok  	github.com/carlosframework/platform/internal/seeds	0.011s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	10.189s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.495s
ok  	github.com/carlosframework/platform/internal/status	0.043s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.215s
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.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.005s
ok  	github.com/carlosframework/platform/internal/vet	1.606s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 46399650 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.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.023s
=== RUN   TestT4StorageProbeMinio
2026/10/15 17:30:22 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEC4326380CEC2, 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.021s
minio contract: PASS
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index df5132644..b237e0260 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -89,7 +89,7 @@ var movesCopyIDs = []string{
"MV-N440", "MV-N441", "MV-N442", "MV-N443",
"MV-N450", "MV-N451", "MV-N454", "MV-N455", "MV-N456",
"MV-N500",
- "MV-N512",
+ "MV-N512", "MV-N513",
}
// copyRow is one row of the record. The File cell is the file the sentence is
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
index 89da8c052..50bc8524d 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -456,3 +456,9 @@ Plan `docs/superpowers/plans/2026-10-15-moves-home-region-part2.md` (amadan #236
| id | file | text | status |
|---|---|---|---|
| MV-N512 | internal/moves/envreport.go (a T3 kind inside MV-N129's list) | region-present | pending |
+
+Task 2b's row (fix round 3) is T3's refusal of a version 1 env report. A v1 report carries no instance layer names, so it cannot say whether a box variable such as HOSTNAME came from the instance's files or the box: a target on this release refuses it before comparing anything, fail closed, until the source runs this release too.
+
+| id | file | text | status |
+|---|---|---|---|
+| MV-N513 | internal/host/movesreport.go | the source reported its launch environment in an older format | pending |
diff --git a/internal/host/claimmoveaccess_test.go b/internal/host/claimmoveaccess_test.go
index b26eccdae..234e124e5 100644
--- a/internal/host/claimmoveaccess_test.go
+++ b/internal/host/claimmoveaccess_test.go
@@ -216,7 +216,7 @@ func (m *plan10InputOwner) InputPolicy(ctx context.Context, c *hostclaim.Claim,
func TestPlan10CommittedInputDispatch(t *testing.T) {
reader := &mpReader{rec: mpRecord()}
reader.rec.Move.State = provision.MoveCommitted
- reader.rec.Move.Inputs.EnvReport = "report"
+ reader.rec.Move.Inputs.EnvReport = "env2:sha256:report" // a v2 pin (moves.CurrentEnvPin)
owner := &plan10InputOwner{movesProbeOwners: &movesProbeOwners{Records: reader.read}}
serving := 0
legacy := func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error {
@@ -307,7 +307,7 @@ func TestPlan10CommittedColdAdmissionInputs(t *testing.T) {
t.Fatal(err)
}
rec := &provision.Record{Host: recoveryHost, Move: &provision.MoveState{ID: "m1", Incarnation: o.Claim.Holder.Incarnation, State: provision.MoveCommitted, Inputs: mpPins()}}
- rec.Move.Inputs.EnvReport = "report"
+ rec.Move.Inputs.EnvReport = "env2:sha256:report" // a v2 pin (moves.CurrentEnvPin)
read := MovesRecordReader(func(context.Context, string, string, string) (*provision.Record, error) { return rec, nil })
owner := &plan10InputOwner{movesProbeOwners: &movesProbeOwners{Records: read}}
bindings := ClaimOwnerBindings{InputPolicy: func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error {
diff --git a/internal/host/movesenvskew_test.go b/internal/host/movesenvskew_test.go
new file mode 100644
index 000000000..4e310556a
--- /dev/null
+++ b/internal/host/movesenvskew_test.go
@@ -0,0 +1,165 @@
+package host
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// Version skew of the env report and pin (home part 2, Task 2b fix round
+// 3). A v1 report carries no instance layer names, so it cannot say
+// whether a box variable was the instance's or the box's: an older
+// producer and a newer one with no instance-set box variables look the
+// same. A v2 participant therefore refuses a v1 report at T3 and a v1 pin
+// at the probe, and a v2 pin is in a form no v1 participant's digest can
+// equal. The older participant is emulated here by its own rule: f4f51ba6's
+// digest, every variable but the agent-owned and box variables, in the
+// "sha256:" form.
+
+// v1EnvDigest is a v1 participant's digest of a report's variables.
+func v1EnvDigest(r moves.EnvReport) string {
+ var names []string
+ for k := range r.Vars {
+ if !moves.AgentOwnedVars[k] && !moves.BoxVars[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])
+ }
+ return "sha256:" + hex.EncodeToString(h.Sum(nil))
+}
+
+// asV1Source rewrites the source's published report as a v1 source writes
+// it: version 1, no instance layer names, the v1 digest.
+func asV1Source(t *testing.T, h *phase1Harness) {
+ t.Helper()
+ ctx := context.Background()
+ rec := h.read(t, p1Host)
+ r := h.sourceReport(t)
+ r.Env.V, r.Env.Instance = 1, nil
+ r.Env.Digest = v1EnvDigest(r.Env)
+ body, err := json.Marshal(r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := moves.PutReplace(ctx, h.s, moves.SourceReportKey(moves.ReportPrefix(rec.Account, rec.App, rec.Host, rec.Move.ID)), body); err != nil {
+ t.Fatal(err)
+ }
+}
+
+const t3OldReportWant = "the source reported its launch environment in an older format"
+
+// TestT3RefusesAnOlderSourceReport: a v1 source with a v2 target. The
+// target refuses the source's v1 report at T3, before comparing, named to
+// named and named to home.
+func TestT3RefusesAnOlderSourceReport(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 := newPhase1Harness(t, opts...)
+ h.start(t, "manual")
+ h.pass(t)
+ asV1Source(t, h)
+ h.targetPass(t, h.targetCfg())
+ r := h.targetReport(t)
+ if t3 := r.Checks[moves.CheckEnv]; t3.OK || t3.Reason != t3OldReportWant {
+ t.Fatalf("T3 on a v1 source report: %+v", t3)
+ }
+ if r.Inputs.EnvReport != "" {
+ t.Fatalf("a refused T3 bound the source's env report: %q", r.Inputs.EnvReport)
+ }
+ })
+ }
+}
+
+// TestProbeRefusesAnOlderPin: a v1 source with a v2 target, and the
+// instance's HOSTNAME removed after the pin. The source's .env set
+// HOSTNAME=a, which a v1 pin leaves out; after the pin the target's .env
+// loses it and the target inherits HOSTNAME=b from its box, which the v2
+// rule also leaves out. The two digests would agree on a changed env; the
+// v2 target refuses the v1 pin instead, named to named and named to home.
+func TestProbeRefusesAnOlderPin(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, append([]p1Option{sourceEnvFile("APP_MODE=prod\nHOSTNAME=a\n")}, opts...)...)
+ p := h.productionProbe(t)
+ v1 := v1EnvDigest(h.sourceReport(t).Env)
+ if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, func(rec *provision.Record) error {
+ rec.Move.Inputs.EnvReport = v1
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(h.tdir, p1Host+".env"), []byte("APP_MODE=prod\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("HOSTNAME", "b")
+ h.land(t)
+ if len(p.admissions) != 0 {
+ t.Fatalf("a v1 pin admitted a changed env: %+v", p.admissions)
+ }
+ if m := h.read(t, p1Host).Move; m.Reason != provision.RollbackConfigChanged || m.State == provision.MoveCommitted {
+ t.Fatalf("the landing on a v1 pin: state %s reason %q", m.State, m.Reason)
+ }
+ })
+ }
+}
+
+// TestOlderTargetCannotMatchANewerPin: a v2 source with a v1 target. A v1
+// target judges its own launch by the v1 digest; whatever its env, that
+// digest never equals a v2 pin, so the older target refuses, fail closed.
+// Both directions of a box variable changing after the pin:
+// - the source's HOSTNAME is inherited (left out of the pin) and the
+// target's .env adds HOSTNAME=b;
+// - the source's .env sets HOSTNAME=a (pinned) and the target's .env
+// loses it, inheriting HOSTNAME=b.
+func TestOlderTargetCannotMatchANewerPin(t *testing.T) {
+ for name, c := range map[string]struct {
+ srcFile, tgtFile string
+ }{
+ "the target adds an instance HOSTNAME": {"APP_MODE=prod\n", "APP_MODE=prod\nHOSTNAME=b\n"},
+ "the target loses the instance HOSTNAME": {"APP_MODE=prod\nHOSTNAME=a\n", "APP_MODE=prod\n"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ t.Setenv("HOSTNAME", "a")
+ h := newCloseHarness(t, sourceEnvFile(c.srcFile), intoHome, realLaunchEnv)
+ pin := h.read(t, p1Host).Move.Inputs.EnvReport
+ if !moves.CurrentEnvPin(pin) {
+ t.Fatalf("the source's pin is not v2: %q", pin)
+ }
+ if err := os.WriteFile(filepath.Join(h.tdir, p1Host+".env"), []byte(c.tgtFile), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("HOSTNAME", "b")
+ env, _, err := (&activator.ExecBackend{DataDir: h.tdir, Region: h.dst}).LaunchEnvironment(h.route)
+ if err != nil {
+ t.Fatal(err)
+ }
+ key, err := moves.ReportKeyFromSecrets(h.cfg.SecretsKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := v1EnvDigest(moves.ReportEnv(key, env)); got == pin {
+ t.Fatalf("a v1 target's digest of a changed env matches the v2 pin %q", pin)
+ }
+ })
+ }
+}
diff --git a/internal/host/movesprobeowners.go b/internal/host/movesprobeowners.go
index 73d970fb8..1fcd9d717 100644
--- a/internal/host/movesprobeowners.go
+++ b/internal/host/movesprobeowners.go
@@ -640,7 +640,9 @@ func (m *movesProbeOwners) Select(ctx context.Context, rt registry.Route, o host
if l.Version != pins.Release {
return ClaimLaunchInputs{}, probeInputsChanged()
}
- if pins.EnvReport == "" || envDigest(m.EnvKey, l.Env, l.Instance) != pins.EnvReport {
+ // A v1 pin is refused whatever it hashes: it cannot tell an instance's
+ // box variable from the box's (moves.EnvReportVersion).
+ if !moves.CurrentEnvPin(pins.EnvReport) || envDigest(m.EnvKey, l.Env, l.Instance) != pins.EnvReport {
return ClaimLaunchInputs{}, probeInputsChanged()
}
return ClaimLaunchInputs{Roles: []string{"main"}, Identity: launchIdentity(pins), Baseline: plan, Admission: admission,
@@ -822,7 +824,7 @@ func (m *movesProbeOwners) Materialize(ctx context.Context, o hostclaim.Observed
if err := m.Release(ctx, rt, pins.Release, heldIdentityWork{Work: released, ID: hostwrite.Identity{Release: pins.Release}}); err != nil {
return err
}
- if envDigest(m.EnvKey, in.Env, in.Instance) != pins.EnvReport {
+ if !moves.CurrentEnvPin(pins.EnvReport) || envDigest(m.EnvKey, in.Env, in.Instance) != pins.EnvReport {
return probeInputsChanged()
}
m.recordFence(o.Host, h.Session, work)
@@ -1450,7 +1452,7 @@ func (m *movesProbeOwners) selectCommitted(ctx context.Context, rt registry.Rout
// on the launch itself: its environment's report and its release.
pins := v.rec.Move.Inputs
id := committedLaunchIdentity(m.EnvKey, l.Env, l.Instance, l.Version)
- if pins.EnvReport == "" || id != pinnedLaunchIdentity(pins) {
+ if !moves.CurrentEnvPin(pins.EnvReport) || id != pinnedLaunchIdentity(pins) {
return ClaimLaunchInputs{}, fmt.Errorf("%w: the launch is not the move's pinned inputs", hostbox.ErrChanged)
}
admission, err := m.committedAdmission(o, v, gen, plan)
@@ -1541,7 +1543,7 @@ func (m *movesProbeOwners) InputPolicy(ctx context.Context, c *hostclaim.Claim,
rec.Move.State != provision.MoveCommitted || rec.Move.Inputs == nil || rec.Move.Inputs.Release == "" {
return hostbox.ErrChanged
}
- if rec.Move.Inputs.EnvReport == "" || id != pinnedLaunchIdentity(rec.Move.Inputs) {
+ if !moves.CurrentEnvPin(rec.Move.Inputs.EnvReport) || id != pinnedLaunchIdentity(rec.Move.Inputs) {
return fmt.Errorf("%w: the committed launch is not the move's pinned inputs", hostbox.ErrChanged)
}
return nil
diff --git a/internal/host/movesreport.go b/internal/host/movesreport.go
index 31cc0fdf3..1d35140c4 100644
--- a/internal/host/movesreport.go
+++ b/internal/host/movesreport.go
@@ -42,25 +42,29 @@ const (
t2Differs = "this box's secrets key is not the console's"
t3NoSource = "the source has not published a readiness report yet" // MV-N62's text
t3NoCompose = "this box could not compose the launch environment of the held row"
- t5NoArchive = "the source has published no archive yet" // MV-N73
- t5Unreadable = "this box could not read the archive the source published"
- t5Refused = "the archive does not open with this box's key or does not match its manifest"
- t5NotPinned = "the source staged its archive without a fixed version id"
- t6Unmeasured = "this box could not measure its free disk space"
- t7NoRelease = "the source has not reported its release yet" // MV-N76
- t7Waiting = "waiting for the root stage pass" // MV-N75
- t7Failed = "this box could not stage the source's release"
- t7NotProven = "this box requires a proof for the source's release and has not accepted one"
- t7NotStaged = "the release is not staged on this box yet" // MV-N455: T7 under the claim, the version store not writable (heldReleaseStaged)
- t9NotThisMove = "the host claim is not held by this move"
- t9Incarnation = "the host claim names another incarnation of this instance"
- t9NoMoveJSON = "the staging lane has no move.json yet"
- t9OtherMove = "the staging lane holds another move's move.json" // MV-N9, aligned
- t10NoBucket = "the source region publishes no data bucket"
- t10Failed = "this box cannot write the return lane in the source's bucket"
- t11NoReplica = "no replica has been staged yet"
- t11Unmeasured = "the database size could not be measured" // MV-N69
- t13NotArmed = "this box cannot land the move or roll it back yet"
+ // t3OldReport is MV-N513: the source's env report is version 1, whose
+ // pin cannot tell an instance's box variable from the box's
+ // (moves.EnvReportVersion).
+ t3OldReport = "the source reported its launch environment in an older format"
+ t5NoArchive = "the source has published no archive yet" // MV-N73
+ t5Unreadable = "this box could not read the archive the source published"
+ t5Refused = "the archive does not open with this box's key or does not match its manifest"
+ t5NotPinned = "the source staged its archive without a fixed version id"
+ t6Unmeasured = "this box could not measure its free disk space"
+ t7NoRelease = "the source has not reported its release yet" // MV-N76
+ t7Waiting = "waiting for the root stage pass" // MV-N75
+ t7Failed = "this box could not stage the source's release"
+ t7NotProven = "this box requires a proof for the source's release and has not accepted one"
+ t7NotStaged = "the release is not staged on this box yet" // MV-N455: T7 under the claim, the version store not writable (heldReleaseStaged)
+ t9NotThisMove = "the host claim is not held by this move"
+ t9Incarnation = "the host claim names another incarnation of this instance"
+ t9NoMoveJSON = "the staging lane has no move.json yet"
+ t9OtherMove = "the staging lane holds another move's move.json" // MV-N9, aligned
+ t10NoBucket = "the source region publishes no data bucket"
+ t10Failed = "this box cannot write the return lane in the source's bucket"
+ t11NoReplica = "no replica has been staged yet"
+ t11Unmeasured = "the database size could not be measured" // MV-N69
+ t13NotArmed = "this box cannot land the move or roll it back yet"
)
func t1Below(min int) string {
@@ -510,6 +514,11 @@ func (w *movesWorker) checkEnv(ctx context.Context, s release.Store, rec provisi
for _, name := range shadowed {
notes = append(notes, t3Shadowed(name))
}
+ // A v1 report cannot say which box variables the instance supplied, so
+ // it is refused before any comparison (fail closed).
+ if !moves.CurrentEnvReport(source.Env) {
+ return held, moves.Check{Reason: t3OldReport}, notes
+ }
// 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.
diff --git a/internal/moves/envreport.go b/internal/moves/envreport.go
index c4eec53e2..dc95fd89a 100644
--- a/internal/moves/envreport.go
+++ b/internal/moves/envreport.go
@@ -123,7 +123,7 @@ func (r EnvReport) instanceSupplied(name string) bool {
// the target's composition of the same instance hash the same
// (PortableDigest). Raw values never appear in the result.
func ReportLaunch(key []byte, env map[string]string, instance map[string]bool) EnvReport {
- r := EnvReport{V: 1, Vars: make(map[string]string, len(env))}
+ r := EnvReport{V: EnvReportVersion, Vars: make(map[string]string, len(env))}
for k, v := range env {
r.Vars[k] = macVar(key, k, v)
if BoxVars[k] && instance[k] {
@@ -156,7 +156,7 @@ func LocalDigest(key []byte, env map[string]string) string {
for k, v := range env {
vars[k] = macVar(key, k, v)
}
- return digestOf(vars, func(string) bool { return true })
+ return "sha256:" + digestOf(vars, func(string) bool { return true })
}
func macVar(key []byte, name, value string) string {
@@ -167,11 +167,35 @@ func macVar(key []byte, name, value string) string {
return hex.EncodeToString(mac.Sum(nil))
}
-// portableDigest hashes the reported variables Pinned keeps.
+// portableDigest hashes the reported variables Pinned keeps, in the v2
+// pin's form.
func (r EnvReport) portableDigest() string {
- return digestOf(r.Vars, func(k string) bool { return Pinned(k, r.instanceSupplied(k)) })
+ return envPinPrefix + digestOf(r.Vars, func(k string) bool { return Pinned(k, r.instanceSupplied(k)) })
}
+// EnvReportVersion is the env report's and the env pin's contract
+// (home part 2, Task 2b fix round 3). Version 2 names the instance's layer
+// names (Instance) and pins a box variable only when the instance supplied
+// it (Pinned). A version 1 report carries no layer names, so an absent
+// Instance cannot tell an older producer, whose box variables were never
+// pinned, from a newer one with none instance-set: a v2 participant
+// refuses a v1 report at T3 and a v1 pin at the probe (CurrentEnvReport,
+// CurrentEnvPin), and a v2 pin is in a form (envPinPrefix) no v1
+// digest, "sha256:<hex>", can equal. This is the report's version, not
+// ProtocolVersion.
+const EnvReportVersion = 2
+
+// envPinPrefix marks a v2 env pin.
+const envPinPrefix = "env2:sha256:"
+
+// CurrentEnvPin reports whether pin is a v2 env pin, the only form this
+// release checks a launch against.
+func CurrentEnvPin(pin string) bool { return strings.HasPrefix(pin, envPinPrefix) }
+
+// CurrentEnvReport reports whether r is a v2 env report: its version and
+// its digest's form both say so.
+func CurrentEnvReport(r EnvReport) bool { return r.V == EnvReportVersion && CurrentEnvPin(r.Digest) }
+
func digestOf(vars map[string]string, keep func(string) bool) string {
names := make([]string, 0, len(vars))
for k := range vars {
@@ -184,7 +208,7 @@ func digestOf(vars map[string]string, keep func(string) bool) string {
for _, k := range names {
fmt.Fprintf(h, "%s=%s\n", k, vars[k])
}
- return "sha256:" + hex.EncodeToString(h.Sum(nil))
+ return hex.EncodeToString(h.Sum(nil))
}
// Class is T3's variable class by name. Every BoxOwned variable has a
diff --git a/internal/moves/envreport_test.go b/internal/moves/envreport_test.go
index 22e3d005a..307457202 100644
--- a/internal/moves/envreport_test.go
+++ b/internal/moves/envreport_test.go
@@ -334,7 +334,7 @@ func TestReportEnvIsDeterministicAndDigestCoversNamesAndValues(t *testing.T) {
key := []byte("0123456789abcdef0123456789abcdef")
a := ReportEnv(key, map[string]string{"A": "1", "B": "2"})
b := ReportEnv(key, map[string]string{"B": "2", "A": "1"})
- if a.Digest != b.Digest || a.V != 1 || !strings.HasPrefix(a.Digest, "sha256:") {
+ if a.Digest != b.Digest || a.V != EnvReportVersion || !CurrentEnvReport(a) || !strings.HasPrefix(a.Digest, "env2:sha256:") {
t.Fatalf("%v %v", a, b)
}
// the mac binds the name: the same value under another name differs
@@ -383,3 +383,33 @@ func TestReportKeyFromSecrets(t *testing.T) {
}
}
}
+
+// TestEnvReportVersionTwo (fix round 3): a report is version 2 and its
+// digest is a v2 pin. A v1 report, or a v1 pin in either v1 form (main's
+// whole-env digest, f4f51ba6's digest without box variables), is not
+// current, and no v1 form of the same env equals the v2 pin. The local
+// digest keeps its own form.
+func TestEnvReportVersionTwo(t *testing.T) {
+ key := []byte("0123456789abcdef0123456789abcdef")
+ env := map[string]string{"A": "1", "HOSTNAME": "box"}
+ r := ReportEnv(key, env)
+ if r.V != 2 || !CurrentEnvReport(r) || !CurrentEnvPin(r.Digest) {
+ t.Fatalf("not a v2 report: %+v", r)
+ }
+ old := r
+ old.V = 1
+ if CurrentEnvReport(old) {
+ t.Fatal("a v1 report is current")
+ }
+ for _, pin := range []string{"", "sha256:" + strings.TrimPrefix(r.Digest, "env2:sha256:"), LocalDigest(key, env), "report"} {
+ if CurrentEnvPin(pin) {
+ t.Errorf("%q is a current pin", pin)
+ }
+ if pin == r.Digest {
+ t.Errorf("a v1 form %q equals the v2 pin", pin)
+ }
+ }
+ if !strings.HasPrefix(LocalDigest(key, env), "sha256:") {
+ t.Fatal("the local digest changed form")
+ }
+}
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index bafb1d2fb..503ef88a1 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -750,7 +750,7 @@ func TestPlan14RootReadCensus(t *testing.T) {
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-home-part2.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
- seal = "b7e249599ad4f2a667887c7ceb82b5452abff8ace919608cd03ece04cd48b628"
+ seal = "8d9a063305974175e1486b3a0b5e61ba298bbff6a637ecf77503c0ca2447015d"
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
index c44fff582..b01fc351b 100644
--- a/internal/provision/testdata/plan14-root-reads-home-part2.tsv
+++ b/internal/provision/testdata/plan14-root-reads-home-part2.tsv
@@ -3,6 +3,7 @@
# Env digests, by where they are compared. Across boxes, the move-portable env: the source pin writeSourceReport 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 envDigest) and by the committed wake (committedLaunchIdentity against pinnedLaunchIdentity, selectCommitted and InputPolicy); the cut's currentInputs (host/movescut.go), compared on the source with that pin. Same box, the whole local env: the recovery's recoveryInputs (host/movescut.go, recorded through host/movesrecoveryprobe.go:139-146 on the recovering box) checked by the recovery owners' Select and Materialize (host/movesrecoveryowners.go localEnvDigest). Same box, full composed env kept and unchanged: 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); snapshot.Identity.Config (host/claimmoveaccess.go:191), which the moves owners never read (they set their own identity from the pins). movesReadLaunch (host/moveswakesource.go:207) discards the launch source's EnvReport, not its identity.
# Re-frozen units, every one at main's disposition (class, task and basis unchanged; kinds and digest refreshed). Authority rows, read set unchanged (only the source offsets moved): host/claimmoveaccess.go claimMoveLaunchSource (authority C,G,J1; it now calls LaunchLayers and returns the instance's layer names) and host/movespass.go armPhase1 (authority B2,C; its LaunchEnv hook now returns the layer names). Legacy rows: activator ExecBackend.LaunchEnvironment (now a wrapper of LaunchLayers) and the new ExecBackend.LaunchLayers (LaunchEnvironment's composition and reads, moved; LaunchEnvironment's disposition); host movesWorker.writeSourceReport (ReportLaunch with the layer names); movesProbeOwners.Select, Materialize, selectCommitted and materializeCommitted (envDigest with the layer names); movesRecoveryOwners.Select and Materialize (localEnvDigest). No new read and no root decision. Nine frozen declarations are added.
# ClaimLaunchDigest (host/claimoperations.go, fix round 3): envelope only, it reads nothing and is no root-read unit. It covers ClaimLaunchInputs.Instance, sorted, only when non-empty, so every launch without the instance's layer names keeps main's digest byte for byte (TestClaimLaunchDigestCoversInstanceOnlyWhenSet pins main's bytes). Its comparisons stay same-box (listed above).
+# Fix round 3: the env report and pin are version 2 (moves.EnvReportVersion, envPinPrefix "env2:sha256:"). T3 (checkEnv) refuses a v1 source report and the probe owners (Select, Materialize, selectCommitted, InputPolicy) refuse a v1 pin, fail closed; movesProbeOwners.Select, Materialize and selectCommitted are re-frozen again at main's dispositions, read sets unchanged. The rows below replace this overlay's earlier, unruled rows for the same units.
@legacy 849ce111c61c3dcedd80595348d0a26c67fb47462d497fba63d52d2c94e1d5d0 internal/activator/backend_exec.go ExecBackend.LaunchEnvironment legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route whole:Route@139 e8853e15b427b80a701b5e10b565f7d4f0b778e0c8185da63266e2408f6b4113
internal/activator/backend_exec.go ExecBackend.LaunchEnvironment legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route whole:Route@139 e8853e15b427b80a701b5e10b565f7d4f0b778e0c8185da63266e2408f6b4113
@legacy 1c98a7ecd93fe84486af8cbc09bd1cb31915c69c16785c3fd3bcdd6d21eb6849 internal/activator/backend_exec.go ExecBackend.LaunchLayers legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:Socket@689,whole:Route@179,whole:Route@685 a82c0f1740d9242da29248be3a009ab34a528df105e3a269b91b49e40c839261
@@ -11,18 +12,18 @@ internal/host/claimmoveaccess.go claimMoveLaunchSource authority C,G,J1 root-rou
internal/host/movespass.go armPhase1 authority B2,C rev3=census-ruling-seeded-instances;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with the S1 probe hook refusing a row whose import gate is set; main's B2,C authority retained registry:ImportGate@8279,whole:Route@8266,whole:Route@8408,whole:Route@8757,whole:Route@8898 b24834b54b93a21e8ba0757fa8f61a9a5eee2ea3c8fa85d634ef4ce4aba1c85d
@legacy 6080eef9badd1adefd1a5c4e7a6dd3c64999b8cf58eb016adbe4e72ab8efbff2 internal/host/movesphase1.go movesWorker.writeSourceReport legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Stat@753 585c0c5bb9b8c20d5a5d77dc08e49720268951ec30bbc1175db9c83900c817f2
internal/host/movesphase1.go movesWorker.writeSourceReport legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Stat@753 585c0c5bb9b8c20d5a5d77dc08e49720268951ec30bbc1175db9c83900c817f2
-@legacy 31e120a3a0d3d9bf8367d682d37bf828ff0d38de82120df7bf90ddf65160def0 internal/host/movesprobeowners.go movesProbeOwners.Materialize legacy accepted-legacy accepted-legacy;rev3=merge-ruling-2;basis=Agent landing materialization adds C8 payload establishment and recovery delegation; generic payload rendering remains legacy. registry:DBPath@1041,whole:Route@1214,whole:Route@2099 411a6da5dd4d7da78124b299c081289c97466b4d54e63e32f7ec1635b5651541
-internal/host/movesprobeowners.go movesProbeOwners.Materialize legacy accepted-legacy accepted-legacy;rev3=merge-ruling-2;basis=Agent landing materialization adds C8 payload establishment and recovery delegation; generic payload rendering remains legacy. registry:DBPath@1041,whole:Route@1214,whole:Route@2099 411a6da5dd4d7da78124b299c081289c97466b4d54e63e32f7ec1635b5651541
-@legacy 1efd44545eb606c4c5b1fb04db18062c4aed2d7bc20d0b8b42e9c89cd0b6531c internal/host/movesprobeowners.go movesProbeOwners.Select legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@1400,registry:DBPath@3028,whole:Route@206,whole:Route@2284,whole:Route@465,whole:Route@765 5a606e96b50c37070313f637d2c4d7f9453dc6f780834e6195bb975099b680fd
-internal/host/movesprobeowners.go movesProbeOwners.Select legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@1400,registry:DBPath@3028,whole:Route@206,whole:Route@2284,whole:Route@465,whole:Route@765 5a606e96b50c37070313f637d2c4d7f9453dc6f780834e6195bb975099b680fd
+@legacy ee65a41f912901bc0645265455b564ab7bfd2dc08875dcade50bec796444bb69 internal/host/movesprobeowners.go movesProbeOwners.Materialize legacy accepted-legacy accepted-legacy;rev3=merge-ruling-2;basis=Agent landing materialization adds C8 payload establishment and recovery delegation; generic payload rendering remains legacy. registry:DBPath@1041,whole:Route@1214,whole:Route@2099 6b21920ef49548fba1bfee767e496851451918eadde1ecc85f2a264eb91d3027
+internal/host/movesprobeowners.go movesProbeOwners.Materialize legacy accepted-legacy accepted-legacy;rev3=merge-ruling-2;basis=Agent landing materialization adds C8 payload establishment and recovery delegation; generic payload rendering remains legacy. registry:DBPath@1041,whole:Route@1214,whole:Route@2099 6b21920ef49548fba1bfee767e496851451918eadde1ecc85f2a264eb91d3027
+@legacy d3d37e02c781782a73bc9044cff03e90440ba490259914550017a0fa0763842c internal/host/movesprobeowners.go movesProbeOwners.Select legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@1400,registry:DBPath@3175,whole:Route@206,whole:Route@2284,whole:Route@465,whole:Route@765 3bd1707831d739695d687fb3074db3f2911bec36fbcd55efadd0f2bec59478cb
+internal/host/movesprobeowners.go movesProbeOwners.Select legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@1400,registry:DBPath@3175,whole:Route@206,whole:Route@2284,whole:Route@465,whole:Route@765 3bd1707831d739695d687fb3074db3f2911bec36fbcd55efadd0f2bec59478cb
@legacy 45cda5b26dc120bbd9f580493ce79219ce21501f3de390c29a859dad6801c5aa internal/host/movesprobeowners.go movesProbeOwners.materializeCommitted legacy accepted-legacy accepted-legacy;rev3=merge-ruling-2;basis=Committed landing materialization establishes C8 files before start check; payload delivery remains legacy. registry:DBPath@629,whole:Route@616,whole:Route@712 f0a7780b71f42a05595eec24ddb3654d621205a403b6d18c0cba4391209b9554
internal/host/movesprobeowners.go movesProbeOwners.materializeCommitted legacy accepted-legacy accepted-legacy;rev3=merge-ruling-2;basis=Committed landing materialization establishes C8 files before start check; payload delivery remains legacy. registry:DBPath@629,whole:Route@616,whole:Route@712 f0a7780b71f42a05595eec24ddb3654d621205a403b6d18c0cba4391209b9554
-@legacy e6ac718f1c049c2b9e937669afb730f4152ab18cd25b571967a0b78982e5749f internal/host/movesprobeowners.go movesProbeOwners.selectCommitted legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@1647,whole:Route@176,whole:Route@611 af0e6f216a63265245cc5cb1db23aedf9dd8d6731383c90de4d0e8b2b53fc401
-internal/host/movesprobeowners.go movesProbeOwners.selectCommitted legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@1647,whole:Route@176,whole:Route@611 af0e6f216a63265245cc5cb1db23aedf9dd8d6731383c90de4d0e8b2b53fc401
+@legacy c270bb7c3c88c9fc63421868b5bccff36fe89e341723b982f7314792e821f5d6 internal/host/movesprobeowners.go movesProbeOwners.selectCommitted legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@1663,whole:Route@176,whole:Route@611 31c9975dbc5307ccdaa70d7b0821b08b717fa716716fac938095d8d01f512357
+internal/host/movesprobeowners.go movesProbeOwners.selectCommitted legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@1663,whole:Route@176,whole:Route@611 31c9975dbc5307ccdaa70d7b0821b08b717fa716716fac938095d8d01f512357
@legacy 7ba24c0aa59e9f30cdf08fdd9e7acb056ab11672ae774772df2c7bb3bbb15ac3 internal/host/movesrecoveryowners.go movesRecoveryOwners.Materialize legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@577,whole:Route@1566,whole:Route@750 a87029143a13dc05d5b873b6b4f099515be737f9e0188586fac5fe91719ccd81
internal/host/movesrecoveryowners.go movesRecoveryOwners.Materialize legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@577,whole:Route@1566,whole:Route@750 a87029143a13dc05d5b873b6b4f099515be737f9e0188586fac5fe91719ccd81
@legacy 98403405dd4f1aa000814d268c71477fd7b3f54fe1921270f8ad3ffc41ec7c54 internal/host/movesrecoveryowners.go movesRecoveryOwners.Select legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@1753,registry:DBPath@792,whole:Route@1008,whole:Route@514 35686d5e12ca3008cfefe0e8a15fcfc8d1e7c120adf94619a55fa948f4554921
internal/host/movesrecoveryowners.go movesRecoveryOwners.Select legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@1753,registry:DBPath@792,whole:Route@1008,whole:Route@514 35686d5e12ca3008cfefe0e8a15fcfc8d1e7c120adf94619a55fa948f4554921
@coverage internal/activator 4b4b243c37f98df546ac893baaf98a80ae805705492dcd0792f532d41976766c
-@coverage internal/host e4e29379586d2c258da32909370f909a2134a87226968785bdfea4babe6f789e
-@coverage internal/moves 14b012bafa4965182c173593563004a1d872436fdfcf03d55bd0b49a0bde85d2
+@coverage internal/host 3699333bae7ea249340acd261f3de74ed3969bb5fcdb4cad988a0b38a2473553
+@coverage internal/moves d395a0a343cbdea42a899ed2fdb2fce7c352f5e6f3a31c2795ccd84a28eebfef