carlos / platform Public

WIP files seed checkpoint 29 (not for merge)

Production bug fixed: reboot can renumber the root block device (259:5 -> 259:1) and mount ID while filesystem identity is unchanged; recovery now treats device and mount numbers as boot-local, bound by persistent filesystem/handle/inode proof. Grown-disk reboot test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 54bafd87462f25496d85d74dc75d94b89cc38189 parent 10a77ee
10 files changed, +451 −107
  • internal/host/claimfilesseed_isolated_rootrunner_linux_test.go +0 −3
  • internal/host/claimfilesseed_reboot_rootrunner_linux_test.go +0 −76
  • internal/host/claimservinggrow_rootrunner_linux_test.go +141 −0
  • internal/host/claimservingidentity.go +2 −2
  • internal/host/claimservingpark_rootrunner_linux_test.go +82 −2
  • internal/host/claimservingreboot_binding.go +4 −4
  • internal/host/claimservingreboot_linux.go +7 −7
  • internal/host/claimservingrenumber_linux_test.go +201 −0
  • internal/host/claimservingtopology_linux.go +4 −3
  • internal/provision/testdata/plan14-root-reads-seed.tsv +10 −10

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	323.750s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.690s
ok  	github.com/carlosframework/platform/cmd/moveprobe	93.425s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.722s
ok  	github.com/carlosframework/platform/internal/activator	249.754s
ok  	github.com/carlosframework/platform/internal/agentsock	0.025s
ok  	github.com/carlosframework/platform/internal/alerting	0.009s
ok  	github.com/carlosframework/platform/internal/analytics	0.020s
ok  	github.com/carlosframework/platform/internal/appconfig	2.527s
ok  	github.com/carlosframework/platform/internal/apperr	0.087s
ok  	github.com/carlosframework/platform/internal/applog	0.159s
ok  	github.com/carlosframework/platform/internal/awsinv	0.005s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.005s
ok  	github.com/carlosframework/platform/internal/boxid	0.734s
?   	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.013s
ok  	github.com/carlosframework/platform/internal/caps	0.012s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.704s
ok  	github.com/carlosframework/platform/internal/changelog	0.348s
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	400.911s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	6.369s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.014s
ok  	github.com/carlosframework/platform/internal/dbverify	0.005s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.004s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.436s
ok  	github.com/carlosframework/platform/internal/doorkey	0.011s
ok  	github.com/carlosframework/platform/internal/economics	0.006s
ok  	github.com/carlosframework/platform/internal/edge	26.759s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.008s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.142s
ok  	github.com/carlosframework/platform/internal/gate	1.685s
ok  	github.com/carlosframework/platform/internal/genselect	0.020s
ok  	github.com/carlosframework/platform/internal/geo	0.015s
ok  	github.com/carlosframework/platform/internal/geoip	0.035s
ok  	github.com/carlosframework/platform/internal/host	168.650s
ok  	github.com/carlosframework/platform/internal/hostactors	0.426s
ok  	github.com/carlosframework/platform/internal/hostbox	2.476s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.580s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.006s
ok  	github.com/carlosframework/platform/internal/hostexec	5.000s
ok  	github.com/carlosframework/platform/internal/hostfence	0.038s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.542s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.016s
ok  	github.com/carlosframework/platform/internal/hostguard	0.982s
ok  	github.com/carlosframework/platform/internal/hostops	1.640s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.002s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.002s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.491s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.150s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.005s
ok  	github.com/carlosframework/platform/internal/instancectl	4.181s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.737s
ok  	github.com/carlosframework/platform/internal/instancefs	0.022s
ok  	github.com/carlosframework/platform/internal/instancekey	0.015s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.024s
ok  	github.com/carlosframework/platform/internal/layout	1.575s
ok  	github.com/carlosframework/platform/internal/lease	9.853s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.015s
ok  	github.com/carlosframework/platform/internal/ledger	0.026s
ok  	github.com/carlosframework/platform/internal/mail	0.206s
ok  	github.com/carlosframework/platform/internal/maintenance	0.080s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.528s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.092s
ok  	github.com/carlosframework/platform/internal/metrics	0.006s
ok  	github.com/carlosframework/platform/internal/moves	21.318s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.804s
ok  	github.com/carlosframework/platform/internal/objstore	0.192s
ok  	github.com/carlosframework/platform/internal/ops	0.174s
ok  	github.com/carlosframework/platform/internal/park	0.226s
ok  	github.com/carlosframework/platform/internal/privatefile	0.004s
ok  	github.com/carlosframework/platform/internal/provision	16.365s
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.993s
ok  	github.com/carlosframework/platform/internal/relay	7.258s
ok  	github.com/carlosframework/platform/internal/release	20.213s
ok  	github.com/carlosframework/platform/internal/rollout	3.704s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.006s
ok  	github.com/carlosframework/platform/internal/rootaws	0.008s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.008s
ok  	github.com/carlosframework/platform/internal/rootstate	0.025s
ok  	github.com/carlosframework/platform/internal/runs	0.570s
ok  	github.com/carlosframework/platform/internal/s3compat	0.006s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.554s
ok  	github.com/carlosframework/platform/internal/seeds	0.012s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	12.342s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.134s
ok  	github.com/carlosframework/platform/internal/status	0.042s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.345s
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.008s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.005s
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.581s
=== 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: 46989474 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.025s
=== RUN   TestT4StorageProbeMinio
2026/10/15 19:27:35 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DECA97CA8EDC46, 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.022s
minio contract: PASS
diff --git a/internal/host/claimfilesseed_isolated_rootrunner_linux_test.go b/internal/host/claimfilesseed_isolated_rootrunner_linux_test.go
index eed1a8202..78608e3cf 100644
--- a/internal/host/claimfilesseed_isolated_rootrunner_linux_test.go
+++ b/internal/host/claimfilesseed_isolated_rootrunner_linux_test.go
@@ -95,9 +95,6 @@ func rootFilesSeedIsolatedWitness(t *testing.T, binary string, twoHosts ...bool)
}
t.Cleanup(func() { os.RemoveAll(dir) })
}
- if t.Name() == "TestRootRebootFilesSeedTwoHostPhase1" || t.Name() == "TestRootRebootFilesSeedTwoHostPhase2" {
- seedRebootDataMount(t)
- }
account, err := user.Lookup("carlos")
if err != nil {
t.Fatal(err)
diff --git a/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go b/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go
index a5664ce30..a874e603e 100644
--- a/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go
+++ b/internal/host/claimfilesseed_reboot_rootrunner_linux_test.go
@@ -10,7 +10,6 @@ import (
"net"
"net/http/httptest"
"os"
- "os/exec"
"path/filepath"
"reflect"
"sort"
@@ -27,7 +26,6 @@ import (
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/rootstate"
- "golang.org/x/sys/unix"
)
const seedRebootPath = "/var/lib/carlos-rootrunner-files-seed-reboot"
@@ -301,77 +299,3 @@ func reopenSeedLiveHost(t *testing.T, saved seedRebootHost, witness filesSeedLiv
p := newServingPreparation(t, f, "boot", fmt.Sprintf("reboot-%d", os.Getpid()))
return &seedLiveHost{p: p, endpoint: server.URL, files: opened.(*movesFiles), witness: witness}
}
-
-// A grown root volume can receive different partition device numbers at its
-// first reboot. The production contract refuses device renumbering. Give this
-// reboot witness its own stable filesystem instead of relaxing that contract.
-func seedRebootDataMount(t *testing.T) {
- t.Helper()
- var root unix.Stat_t
- if err := unix.Stat("/", &root); err != nil {
- t.Fatal(err)
- }
- t.Logf("runner root device before data mount: %d", root.Dev)
- image := filepath.Join(seedRebootPath, "data.ext4")
- deviceFile := filepath.Join(seedRebootPath, "data-device")
- command := func(args ...string) string {
- t.Helper()
- out, err := exec.CommandContext(t.Context(), args[0], args[1:]...).CombinedOutput()
- if err != nil {
- t.Fatalf("reboot data command %q: %v %s", args, err, out)
- }
- return strings.TrimSpace(string(out))
- }
- var device string
- if t.Name() == "TestRootRebootFilesSeedTwoHostPhase1" {
- file, err := os.OpenFile(image, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600)
- if err != nil {
- t.Fatal(err)
- }
- err = file.Truncate(2 << 30)
- closeErr := file.Close()
- if err != nil || closeErr != nil {
- t.Fatal(err, closeErr)
- }
- command("mkfs.ext4", "-q", "-F", image)
- device = command("losetup", "--find", "--show", image)
- if err := os.WriteFile(deviceFile, []byte(device), 0600); err != nil {
- t.Fatal(err)
- }
- } else {
- body, err := os.ReadFile(deviceFile)
- if err != nil {
- t.Fatal(err)
- }
- device = string(body)
- number := strings.TrimPrefix(device, "/dev/loop")
- if number == device || number == "" {
- t.Fatal("invalid saved loop device", device)
- }
- if _, err := strconv.ParseUint(number, 10, 32); err != nil {
- t.Fatal(err)
- }
- // losetup refuses a device already in use; never detach someone else's loop.
- command("losetup", device, image)
- }
- t.Cleanup(func() {
- cleanup, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- if out, err := exec.CommandContext(cleanup, "losetup", "--detach", device).CombinedOutput(); err != nil {
- t.Errorf("detach fixture loop: %v %s", err, out)
- }
- })
- if err := unix.Mount(device, "/data", "ext4", unix.MS_NOSUID|unix.MS_NODEV, ""); err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() {
- if err := unix.Unmount("/data", 0); err != nil {
- t.Error(err)
- }
- })
- var data unix.Stat_t
- if err := unix.Stat("/data", &data); err != nil {
- t.Fatal(err)
- }
- t.Logf("persistent data device: %s (%d)", device, data.Dev)
-}
diff --git a/internal/host/claimservinggrow_rootrunner_linux_test.go b/internal/host/claimservinggrow_rootrunner_linux_test.go
new file mode 100644
index 000000000..b61f7df3b
--- /dev/null
+++ b/internal/host/claimservinggrow_rootrunner_linux_test.go
@@ -0,0 +1,141 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+func TestRootRebootServingParkGrownDiskPhase1(t *testing.T) {
+ TestRootRebootServingParkChangedMountPhase1(t)
+}
+
+func TestRootRebootServingParkGrownDiskPhase2(t *testing.T) {
+ TestRootRebootServingParkChangedMountPhase2(t)
+}
+
+// Capture retirement on a small ext4 filesystem, grow it online, then attach it
+// under a different device number after reboot. Neither identity change is a seam.
+func servingRunnerGrownDisk(t *testing.T, data string, first bool) func() {
+ t.Helper()
+ command := func(args ...string) string {
+ t.Helper()
+ out, err := exec.CommandContext(t.Context(), args[0], args[1:]...).CombinedOutput()
+ if err != nil {
+ t.Fatalf("grown disk %q: %v %s", args, err, out)
+ }
+ return strings.TrimSpace(string(out))
+ }
+ image := filepath.Join(servingRebootRoot, "grow.ext4")
+ deviceFile := filepath.Join(servingRebootRoot, "grow.device")
+ uuidFile := filepath.Join(servingRebootRoot, "grow.uuid")
+ create := func(path string, size int64) {
+ t.Helper()
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = f.Truncate(size)
+ closeErr := f.Close()
+ if err != nil || closeErr != nil {
+ t.Fatal(err, closeErr)
+ }
+ }
+ detach := func(device string) {
+ t.Cleanup(func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if out, err := exec.CommandContext(ctx, "losetup", "--detach", device).CombinedOutput(); err != nil {
+ t.Errorf("detach %s: %v %s", device, err, out)
+ }
+ })
+ }
+ if first {
+ if err := os.Mkdir(data, 0700); err != nil {
+ t.Fatal(err)
+ }
+ create(image, 128<<20)
+ command("mkfs.ext4", "-q", "-F", image)
+ } else {
+ old, err := os.ReadFile(deviceFile)
+ if err != nil {
+ t.Fatal(err)
+ }
+ suffix := strings.TrimPrefix(string(old), "/dev/loop")
+ if suffix == string(old) {
+ t.Fatal("invalid loop path")
+ }
+ if _, err := strconv.ParseUint(suffix, 10, 32); err != nil {
+ t.Fatal(err)
+ }
+ blocker := filepath.Join(servingRebootRoot, "blocker")
+ create(blocker, 1<<20)
+ command("losetup", string(old), blocker)
+ detach(string(old))
+ }
+ device := command("losetup", "--find", "--show", image)
+ detach(device)
+ uuid := command("blkid", "-s", "UUID", "-o", "value", device)
+ if first {
+ if err := os.WriteFile(deviceFile, []byte(device), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(uuidFile, []byte(uuid), 0600); err != nil {
+ t.Fatal(err)
+ }
+ } else {
+ old, err := os.ReadFile(deviceFile)
+ if err != nil || string(old) == device {
+ t.Fatal("device did not change", err)
+ }
+ saved, err := os.ReadFile(uuidFile)
+ if err != nil || uuid == "" || string(saved) != uuid {
+ t.Fatal("filesystem UUID changed", err)
+ }
+ }
+ if err := unix.Mount(device, data, "ext4", unix.MS_NOSUID|unix.MS_NODEV, ""); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := unix.Unmount(data, 0); err != nil {
+ t.Error(err)
+ }
+ })
+ var st unix.Statfs_t
+ if err := unix.Statfs(data, &st); err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("grown filesystem: device=%s uuid=%s blocks=%d blocksize=%d", device, uuid, st.Blocks, st.Bsize)
+ if !first {
+ if st.Blocks*uint64(st.Bsize) <= 128<<20 {
+ t.Fatal("grown capacity lost across reboot")
+ }
+ return nil
+ }
+ return func() {
+ t.Helper()
+ if err := os.Truncate(image, 256<<20); err != nil {
+ t.Fatal(err)
+ }
+ command("losetup", "--set-capacity", device)
+ t.Log(command("resize2fs", device))
+ var grown unix.Statfs_t
+ if err := unix.Statfs(data, &grown); err != nil {
+ t.Fatal(err)
+ }
+ if grown.Blocks <= st.Blocks || grown.Fsid != st.Fsid || command("blkid", "-s", "UUID", "-o", "value", device) != uuid {
+ t.Fatal("grow changed identity or did not increase capacity")
+ }
+ command("sync", "-f", data)
+ t.Logf("grew filesystem: blocks %d -> %d, filesystem identity unchanged", st.Blocks, grown.Blocks)
+ }
+}
diff --git a/internal/host/claimservingidentity.go b/internal/host/claimservingidentity.go
index 02a05e675..f14755439 100644
--- a/internal/host/claimservingidentity.go
+++ b/internal/host/claimservingidentity.go
@@ -7,8 +7,8 @@ import (
"github.com/carlosframework/platform/internal/hostbox"
)
-// Handles identify an inode within its filesystem across boots. Device and
-// inode numbers remain required too; these handles never authorize an open.
+// Handles identify an inode within its filesystem across boots. Current-boot
+// device, mount and inode checks still apply; handles never authorize an open.
type servingParkPersistentDirectory struct {
Filesystem [2]int32
Type int32
diff --git a/internal/host/claimservingpark_rootrunner_linux_test.go b/internal/host/claimservingpark_rootrunner_linux_test.go
index 1d5e2df4b..52ce8dc5e 100644
--- a/internal/host/claimservingpark_rootrunner_linux_test.go
+++ b/internal/host/claimservingpark_rootrunner_linux_test.go
@@ -392,6 +392,10 @@ func TestRootRebootServingParkChangedMountPhase1(t *testing.T) {
t.Fatal(err)
}
data := filepath.Join(servingRebootRoot, "data")
+ var grow func()
+ if strings.Contains(t.Name(), "GrownDisk") {
+ grow = servingRunnerGrownDisk(t, data, true)
+ }
servingRunnerMount(t, data)
state, err := rootstate.Open(filepath.Join(servingRebootRoot, "authority"), true, false)
if err != nil {
@@ -399,6 +403,7 @@ func TestRootRebootServingParkChangedMountPhase1(t *testing.T) {
}
defer state.Close()
r := servingRunnerPark(t, data, state)
+ servingRunnerTopologyDiagnostic(t, newServingParkFS(data, state))
boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
if err != nil {
t.Fatal(err)
@@ -406,6 +411,9 @@ func TestRootRebootServingParkChangedMountPhase1(t *testing.T) {
if err := (&servingParkLedger{state: state}).write("reboot.json", servingRebootWitness{Boot: strings.TrimSpace(string(boot)), Record: r}); err != nil {
t.Fatal(err)
}
+ if grow != nil {
+ grow()
+ }
}
func TestRootRebootServingParkChangedMountPhase2(t *testing.T) {
@@ -433,6 +441,9 @@ func TestRootRebootServingParkChangedMountPhase2(t *testing.T) {
}
})
data := filepath.Join(servingRebootRoot, "data")
+ if strings.Contains(t.Name(), "GrownDisk") {
+ servingRunnerGrownDisk(t, data, false)
+ }
// Mount IDs can be reused across boots. Add bind layers until this fixture
// has a different ID, instead of assuming reboot alone changes the number.
fs := newServingParkFS(data, state)
@@ -462,10 +473,41 @@ func TestRootRebootServingParkChangedMountPhase2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
+ servingRunnerTopologyDiagnostic(t, fs)
observation, err := fs.inspectRebootTopology(t.Context())
if err != nil || observation.BootID != strings.TrimSpace(string(boot)) || observation.Previous.BootID != saved.Boot || observation.CurrentParent.Mount == observation.Previous.Parent.Mount {
t.Fatal("persistent topology inspection after actual reboot", observation, err)
}
+ for _, replacement := range []string{"directory-bind", "filesystem"} {
+ t.Run(replacement, func(t *testing.T) {
+ path := filepath.Join(data, "instances", ".serving-parked")
+ if replacement == "directory-bind" {
+ other := filepath.Join(data, "replacement")
+ if err := os.Mkdir(other, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := unix.Mount(other, path, "", unix.MS_BIND, ""); err != nil {
+ t.Fatal(err)
+ }
+ } else if err := unix.Mount("tmpfs", path, "tmpfs", unix.MS_NOSUID|unix.MS_NODEV, "size=1m,mode=0700"); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := unix.Unmount(path, 0); err != nil {
+ t.Error(err)
+ }
+ })
+ if _, err := fs.inspectRebootTopology(t.Context()); err == nil {
+ t.Fatal("replacement mount accepted across reboot")
+ }
+ if body, err := state.Read("namespace.json"); err != nil || !bytes.Equal(body, before) {
+ t.Fatal("replacement refusal changed authority", err)
+ }
+ })
+ }
+ if _, err := fs.inspectRebootTopology(t.Context()); err != nil {
+ t.Fatal("original mount after replacement controls", err)
+ }
parentAfter, err := state.Read("namespace-parent.json")
if err != nil || !bytes.Equal(parentBefore, parentAfter) {
t.Fatal("inspection rewrote parent authority", err)
@@ -523,7 +565,7 @@ func TestRootRebootServingParkChangedMountPhase2(t *testing.T) {
}
// The kernel need not recycle the number in this run. Normalize only this
- // fixture's saved mount numbers to the actual new mount, leaving the real
+ // fixture's saved device and mount numbers to the actual new mount, leaving the real
// pre-reboot BootID untouched. A mount-number-only guard would now accept it.
parent, actual, err := fs.parent()
if err != nil {
@@ -534,10 +576,11 @@ func TestRootRebootServingParkChangedMountPhase2(t *testing.T) {
if servingParkDecode(before, &namespace) != nil || namespace.BootID != saved.Boot {
t.Fatal("phase 1 did not persist its actual boot")
}
- if actual.Device != saved.Record.Source.Parent.Device || actual.Inode != saved.Record.Source.Parent.Inode {
+ if actual.Inode != saved.Record.Source.Parent.Inode {
t.Fatal("reboot fixture changed its underlying parent identity")
}
namespace.Parent.Mount, namespace.Root.Mount = actual.Mount, actual.Mount
+ namespace.Parent.Device, namespace.Root.Device = actual.Device, actual.Device
if err := (&servingParkLedger{state: state}).write("namespace.json", namespace); err != nil {
t.Fatal(err)
}
@@ -545,9 +588,13 @@ func TestRootRebootServingParkChangedMountPhase2(t *testing.T) {
r.Authorization.Directory.Mount = actual.Mount
r.Source.Directory.Mount, r.Source.Parent.Mount = actual.Mount, actual.Mount
r.Event.Mount, r.Namespace.Mount = actual.Mount, actual.Mount
+ r.Authorization.Directory.Device = actual.Device
+ r.Source.Directory.Device, r.Source.Parent.Device = actual.Device, actual.Device
+ r.Event.Device, r.Namespace.Device = actual.Device, actual.Device
for i := range r.Source.Files {
if r.Source.Files[i].Present {
r.Source.Files[i].Mount = actual.Mount
+ r.Source.Files[i].Device = actual.Device
}
}
before, err = state.Read("namespace.json")
@@ -685,6 +732,39 @@ func TestRootRebootServingParkAcceptancePhase2(t *testing.T) {
TestRootRebootServingParkChangedMountPhase2(t)
}
+func servingRunnerTopologyDiagnostic(t *testing.T, fs *servingParkFS) {
+ t.Helper()
+ body, err := fs.state.Read("namespace.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("recorded topology: %s", body)
+ parent, pid, err := fs.parent()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer parent.Close()
+ root, rid, err := fs.privateDir(parent, ".serving-parked")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer root.Close()
+ boot, err := fs.currentBoot()
+ if err != nil {
+ t.Fatal(err)
+ }
+ topology, err := fs.captureTopology(parent, root, servingParkNamespace{Parent: pid, Root: rid, BootID: boot})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("current topology: boot=%s parent=%+v root=%+v persistent=%+v", boot, pid, rid, topology)
+ out, err := exec.CommandContext(t.Context(), "findmnt", "-n", "-o", "UUID,MAJ:MIN,SOURCE", "-T", filepath.Join(fs.data, "instances")).CombinedOutput()
+ if err != nil {
+ t.Fatal(err, string(out))
+ }
+ t.Logf("current filesystem UUID/device/source: %s", out)
+}
+
func TestRootFilesSeedRealOriginPreparation(t *testing.T) {
runRootFilesSeedRealOriginPreparation(t, false)
}
diff --git a/internal/host/claimservingreboot_binding.go b/internal/host/claimservingreboot_binding.go
index 977d985d3..23705c011 100644
--- a/internal/host/claimservingreboot_binding.go
+++ b/internal/host/claimservingreboot_binding.go
@@ -8,7 +8,7 @@ import (
"github.com/carlosframework/platform/internal/rootstate"
)
-// A binding changes only boot-local mount numbers. Durable records keep their
+// A binding changes only boot-local device and mount numbers. Durable records keep their
// original identities; progress writes translate back to those identities.
type servingParkBootBinding struct {
Version int
@@ -25,7 +25,7 @@ func servingParkReadBinding(state rootstate.Store, id string) (*servingParkBootB
return nil, err
}
var b servingParkBootBinding
- if servingParkDecode(body, &b) != nil || b.Version != 1 || b.ID != id || !validServingParkBootID(b.BeforeBoot) || !validServingParkBootID(b.BootID) || b.BeforeBoot == b.BootID || b.BeforeParent.Device == 0 || b.BeforeParent.Inode == 0 || b.BeforeParent.Mount == 0 || b.Parent.Device != b.BeforeParent.Device || b.Parent.Inode != b.BeforeParent.Inode || b.Parent.Mount == 0 {
+ if servingParkDecode(body, &b) != nil || b.Version != 1 || b.ID != id || !validServingParkBootID(b.BeforeBoot) || !validServingParkBootID(b.BootID) || b.BeforeBoot == b.BootID || b.BeforeParent.Device == 0 || b.BeforeParent.Inode == 0 || b.BeforeParent.Mount == 0 || b.Parent.Device == 0 || b.Parent.Inode != b.BeforeParent.Inode || b.Parent.Mount == 0 {
return nil, hostbox.ErrChanged
}
return &b, nil
@@ -42,7 +42,7 @@ func (b servingParkBootBinding) directory(d *servingArtifactDirectory, reverse b
if d.Device != from.Device || d.Mount != from.Mount {
return hostbox.ErrChanged
}
- d.Mount = to.Mount
+ d.Device, d.Mount = to.Device, to.Mount
return nil
}
@@ -80,7 +80,7 @@ func (b servingParkBootBinding) record(r *servingParkRecord, reverse bool) error
if err := b.directory(&d, reverse); err != nil {
return err
}
- f.Mount = d.Mount
+ f.Device, f.Mount = d.Device, d.Mount
}
return nil
}
diff --git a/internal/host/claimservingreboot_linux.go b/internal/host/claimservingreboot_linux.go
index dcb3e6565..5b4ef8d6e 100644
--- a/internal/host/claimservingreboot_linux.go
+++ b/internal/host/claimservingreboot_linux.go
@@ -87,7 +87,7 @@ func (f *servingParkFS) observeRebootRecord(ctx context.Context, id string) (ser
return out, err
}
defer source.Close()
- out.Source, err = f.rebootDirectory(source, r.Source.Directory, r.Source.Persistent, pid.Mount)
+ out.Source, err = f.rebootDirectory(source, r.Source.Directory, r.Source.Persistent, pid)
if err != nil {
return out, err
}
@@ -104,7 +104,7 @@ func (f *servingParkFS) observeRebootRecord(ctx context.Context, id string) (ser
if !r.Prepared || r.EventRemoved {
return out, hostbox.ErrChanged
}
- out.Event, err = f.rebootDirectory(event, r.Event, r.EventPersistent, pid.Mount)
+ out.Event, err = f.rebootDirectory(event, r.Event, r.EventPersistent, pid)
if err != nil || out.Event != eid {
return out, hostbox.ErrChanged
}
@@ -141,7 +141,7 @@ func (f *servingParkFS) observeRebootRecord(ctx context.Context, id string) (ser
return out, err
}
}
- if !servingParkRebootFileMatches(item, src, dst, pid.Mount, r) {
+ if !servingParkRebootFileMatches(item, src, dst, pid, r) {
return out, hostbox.ErrChanged
}
}
@@ -155,13 +155,13 @@ func (f *servingParkFS) observeRebootRecord(ctx context.Context, id string) (ser
return out, nil
}
-func (f *servingParkFS) rebootDirectory(d *os.File, old servingArtifactDirectory, proof servingParkPersistentDirectory, mount uint64) (servingArtifactDirectory, error) {
+func (f *servingParkFS) rebootDirectory(d *os.File, old servingArtifactDirectory, proof servingParkPersistentDirectory, parent servingArtifactDirectory) (servingArtifactDirectory, error) {
var zero servingArtifactDirectory
id, err := servingParkDirID(d)
if err != nil {
return zero, err
}
- if !proof.valid() || id.Device != old.Device || id.Inode != old.Inode || id.Mount != mount {
+ if !proof.valid() || id.Device != parent.Device || id.Inode != old.Inode || id.Mount != parent.Mount {
return zero, hostbox.ErrChanged
}
actual, err := f.handle(d)
@@ -174,7 +174,7 @@ func (f *servingParkFS) rebootDirectory(d *os.File, old servingArtifactDirectory
return id, nil
}
-func servingParkRebootFileMatches(old, src, dst servingParkFile, mount uint64, r servingParkRecord) bool {
+func servingParkRebootFileMatches(old, src, dst servingParkFile, parent servingArtifactDirectory, r servingParkRecord) bool {
if !old.Present {
return !src.Present && !dst.Present
}
@@ -182,7 +182,7 @@ func servingParkRebootFileMatches(old, src, dst servingParkFile, mount uint64, r
return false
}
expected := old
- expected.Mount = mount
+ expected.Device, expected.Mount = parent.Device, parent.Mount
if old.Deleted || r.EventRemoved {
return !src.Present && !dst.Present
}
diff --git a/internal/host/claimservingrenumber_linux_test.go b/internal/host/claimservingrenumber_linux_test.go
new file mode 100644
index 000000000..de5e1aefe
--- /dev/null
+++ b/internal/host/claimservingrenumber_linux_test.go
@@ -0,0 +1,201 @@
+//go:build linux
+
+package host
+
+import (
+ "errors"
+ "os"
+ "reflect"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+)
+
+// Put the historical records on a different boot-local device number without
+// changing any persistent proof or anything in the actual filesystem.
+func servingRenumberHistory(t *testing.T, f *servingParkDiskFixture, r *servingParkRecord) {
+ t.Helper()
+ old := r.Source.Parent.Device + 123
+ for _, d := range []*servingArtifactDirectory{&r.Source.Parent, &r.Source.Directory, &r.Authorization.Directory, &r.Namespace, &r.Event} {
+ if d.Inode != 0 {
+ d.Device = old
+ }
+ }
+ for i := range r.Source.Files {
+ if r.Source.Files[i].Present {
+ r.Source.Files[i].Device = old
+ }
+ }
+ if err := f.ledger.save(*r); err != nil {
+ t.Fatal(err)
+ }
+ p, err := f.fs.historicalNamespaceParent()
+ if err != nil {
+ t.Fatal(err)
+ }
+ p.Parent.Device = old
+ if err := f.ledger.write("namespace-parent.json", p); err != nil {
+ t.Fatal(err)
+ }
+ var n servingParkNamespace
+ body, err := f.disk.Read("namespace.json")
+ if err != nil || servingParkDecode(body, &n) != nil {
+ t.Fatal(err)
+ }
+ n.Parent.Device, n.Root.Device = old, old
+ if err := f.ledger.write("namespace.json", n); err != nil {
+ t.Fatal(err)
+ }
+ if r.Authorization.Legacy {
+ c, err := f.ledger.legacyConsumption(r.Authorization.Successor)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c == nil {
+ return
+ }
+ c.Source, c.Authorization = r.Source, r.Authorization
+ if err := f.ledger.write(servingLegacyKey("legacy", r.Authorization.Successor), c); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func TestServingParkRebootDeviceRenumber(t *testing.T) {
+ for _, phase := range []string{"captured", "prepared", "rename-reply", "parked", "complete", "unlink-reply", "partial-unlink", "rmdir-reply", "closed"} {
+ t.Run(phase, func(t *testing.T) {
+ f, r := servingRebootRecordFixture(t, phase)
+ servingRenumberHistory(t, f, &r)
+ before := servingBootSnapshot(t, f.disk.dir)
+ got, err := f.fs.inspectRebootRecord(t.Context(), r.Authorization.ID)
+ if err != nil || got.Topology.CurrentParent.Device == r.Source.Parent.Device || !reflect.DeepEqual(got.Record, r) {
+ t.Fatal("renumbered-device inspection", got, err)
+ }
+ if !reflect.DeepEqual(before, servingBootSnapshot(t, f.disk.dir)) {
+ t.Fatal("inspection rewrote history")
+ }
+ if err := f.fs.CheckParked(&r); err == nil {
+ t.Fatal("inspection granted old-boot authority")
+ }
+ if phase == "captured" || phase == "prepared" || phase == "rename-reply" {
+ return
+ }
+ var active *servingParkRebootAuthority
+ if !r.Complete {
+ a := r.activeAuthorization()
+ active = &servingParkRebootAuthority{ClaimETag: "fixture", Actor: hostbox.Actor{Box: a.Successor.Box, Region: a.Successor.Region, Enrollment: "fixture", Revision: 1, Eligible: true}, Directory: got.Source, Tags: a.TagsFrom}
+ }
+ if err := f.ledger.prepareRebootLocked(t.Context(), r, active); err != nil {
+ t.Fatal("prepare", err)
+ }
+ if err := f.ledger.commitRebootLocked(t.Context(), r, active); err != nil {
+ t.Fatal("commit", err)
+ }
+ current, err := f.ledger.read(r.Authorization.ID)
+ if err != nil || current.Source.Parent.Device != got.Topology.CurrentParent.Device {
+ t.Fatal("current device", current, err)
+ }
+ if err := f.ledger.save(current); err != nil {
+ t.Fatal("reverse binding", err)
+ }
+ raw, err := f.ledger.readHistorical(r.Authorization.ID)
+ if err != nil || !reflect.DeepEqual(raw, r) {
+ t.Fatal("round trip changed history", raw, err)
+ }
+ if r.Parked && !r.Deleting {
+ if err := f.fs.CheckParked(&current); !r.Complete && err != nil {
+ t.Fatal("bound park", err)
+ }
+ if r.Complete {
+ f.now = current.ReapAfter
+ if err := f.ledger.reapLocked(t.Context(), r.Authorization.ID); err != nil {
+ t.Fatal("reap", err)
+ }
+ if _, err := os.Stat(f.fs.data + "/instances/.serving-parked/" + r.Authorization.ID); !os.IsNotExist(err) {
+ t.Fatal("event survived reap", err)
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestServingParkRebootDeviceRenumberRefusesReplacement(t *testing.T) {
+ for _, fault := range []string{"filesystem", "root-handle", "source-handle", "event-handle", "file-handle", "inode", "same-boot"} {
+ t.Run(fault, func(t *testing.T) {
+ f, r := servingRebootRecordFixture(t, "parked")
+ servingRenumberHistory(t, f, &r)
+ var n servingParkNamespace
+ body, err := f.disk.Read("namespace.json")
+ if err != nil || servingParkDecode(body, &n) != nil {
+ t.Fatal(err)
+ }
+ switch fault {
+ case "filesystem":
+ n.Topology.Parent.Filesystem[0]++
+ n.Topology.Root.Filesystem = n.Topology.Parent.Filesystem
+ case "root-handle":
+ n.Topology.Root.Handle = "aa"
+ case "source-handle":
+ r.Source.Persistent.Handle = "aa"
+ case "event-handle":
+ r.EventPersistent.Handle = "aa"
+ case "file-handle":
+ r.Source.Files[0].Persistent.Handle = "aa"
+ case "inode":
+ r.Source.Directory.Inode++
+ r.Authorization.Directory = r.Source.Directory
+ case "same-boot":
+ f.fs.boot = func() (string, error) { return r.Source.BootID, nil }
+ }
+ if err := f.ledger.save(r); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.ledger.write("namespace.json", n); err != nil {
+ t.Fatal(err)
+ }
+ before := servingBootSnapshot(t, f.disk.dir)
+ if _, err := f.fs.inspectRebootRecord(t.Context(), r.Authorization.ID); err == nil {
+ t.Fatal("renumbering accepted replaced identity")
+ }
+ if !reflect.DeepEqual(before, servingBootSnapshot(t, f.disk.dir)) {
+ t.Fatal("refusal changed authority")
+ }
+ })
+ }
+}
+
+func TestServingParkRebootDeviceRenumberLostWrite(t *testing.T) {
+ for _, before := range []bool{true, false} {
+ for at := 1; at <= 3; at++ {
+ f, r := servingRebootRecordFixture(t, "complete")
+ servingRenumberHistory(t, f, &r)
+ fault := &servingParkFaultStore{Store: f.disk, failAt: at, before: before}
+ f.ledger.state, f.fs.state = fault, fault
+ root := &servingParkRoot{ledger: f.ledger}
+ err := root.commitServingReboot(t.Context(), r.Authorization.ID)
+ if !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("%v/%d: missing write fault: %v", before, at, err)
+ }
+ f.ledger.state, f.fs.state = f.disk, f.disk
+ if err := root.commitServingReboot(t.Context(), r.Authorization.ID); err != nil {
+ t.Fatal("commit replay", err)
+ }
+ if err := f.fs.recoverRecordedNamespace(t.Context()); err != nil {
+ t.Fatal("namespace recovery", err)
+ }
+ current, err := f.ledger.read(r.Authorization.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.now = current.ReapAfter
+ if err := f.ledger.reapLocked(t.Context(), r.Authorization.ID); err != nil {
+ t.Fatal("reap after lost reply", err)
+ }
+ raw, err := f.ledger.readHistorical(r.Authorization.ID)
+ if err != nil || raw.Source.BootID != r.Source.BootID || raw.Source.Parent != r.Source.Parent || !raw.Closed {
+ t.Fatal("history after recovery", raw, err)
+ }
+ }
+ }
+}
diff --git a/internal/host/claimservingtopology_linux.go b/internal/host/claimservingtopology_linux.go
index a463ea951..d223689d6 100644
--- a/internal/host/claimservingtopology_linux.go
+++ b/internal/host/claimservingtopology_linux.go
@@ -141,9 +141,10 @@ func (f *servingParkFS) inspectRebootTopology(ctx context.Context) (servingParkR
return zero, err
}
defer root.Close()
- // Device renumbering remains a refusal. Persistent evidence is additional
- // to the original inode/device checks, never a reason to drop them.
- if pid.Device != n.Parent.Device || pid.Inode != n.Parent.Inode || rid.Device != n.Root.Device || rid.Inode != n.Root.Inode || rid.Mount != pid.Mount {
+ // Device and mount numbers are boot-local. Across boots, require the same
+ // filesystem and persistent handles below, plus unchanged inode numbers.
+ // Both directories must still share the current device and mount.
+ if pid.Inode != n.Parent.Inode || rid.Device != pid.Device || rid.Inode != n.Root.Inode || rid.Mount != pid.Mount {
return zero, hostbox.ErrChanged
}
current := servingParkNamespace{Version: 1, Parent: pid, Root: rid, BootID: boot}
diff --git a/internal/provision/testdata/plan14-root-reads-seed.tsv b/internal/provision/testdata/plan14-root-reads-seed.tsv
index a0c1f85a7..e41d97382 100644
--- a/internal/provision/testdata/plan14-root-reads-seed.tsv
+++ b/internal/provision/testdata/plan14-root-reads-seed.tsv
@@ -237,18 +237,18 @@ internal/host/claimservingquarantine_linux.go servingParkQuarantine.name authori
internal/host/claimservingquarantine_linux.go servingParkQuarantine.unrecordedName authority C,J4 rev3=PROPOSED-seed; fixed retention basename from the existing private quarantine nonce; no caller path or new nonce on retry e037771d8e939a426c26104c10b92a2b205376d14574a92015f6f5a4bff90375
internal/host/claimservingquarantine_linux.go servingParkQuarantine.validate authority C,J4 rev3=PROPOSED-seed; private quarantine shape and phase checks; original, prior, unrecorded and fresh directories must have distinct same-mount identities; retention only; prior namespace and quarantine must carry the same boot scope 285f4c277e15af5001ff7d69723d1ff84a6137c8d08bedf5a7c8580d2e246398
internal/host/claimservingquarantine_linux.go type servingParkQuarantine authority C,J4 rev3=PROPOSED-seed; private versioned quarantine intent; additive unrecorded-replacement identity and rename progress bounded to one spare; no artifact deletion authority; additive BootID scopes parent, obstruction, prior root and replacement identities 602321aa523aa4d4b9bade287b70abf2c86f4daeb6bb245bc7f5412b688a1680
-internal/host/claimservingreboot_binding.go servingParkBootBinding.directory authority C,J4 rev3=PROPOSED-seed; private current-boot bindings; validate original boot/parent before translating only mount identities; historical retirement/legacy bytes preserved 791c49920c31e1db6826f6d285731669226f12dd0f277ce04457630b6df014d7
-internal/host/claimservingreboot_binding.go servingParkBootBinding.record authority C,J4 rev3=PROPOSED-seed; private current-boot bindings; validate original boot/parent before translating only mount identities; historical retirement/legacy bytes preserved 0ad6e9a4a807af142d647ed5edfbd772a32dcf9da5f3eaffeafaab019a677b57
-internal/host/claimservingreboot_binding.go servingParkLedger.bindRecord authority C,J4 rev3=PROPOSED-seed; private current-boot bindings; validate original boot/parent before translating only mount identities; historical retirement/legacy bytes preserved e98b72dbd294f4a0b57bad42a2d6b3a429d56518db56dbdf36405529130aa513
-internal/host/claimservingreboot_binding.go servingParkReadBinding authority C,J4 rev3=PROPOSED-seed; private current-boot bindings; validate original boot/parent before translating only mount identities; historical retirement/legacy bytes preserved file:Read@111 1b4ea3111bdadb39201c67fe3ba5bc5505ef5f7da2a3f9beb485dbe8bc72708e
-internal/host/claimservingreboot_binding.go type servingParkBootBinding authority C,J4 rev3=PROPOSED-seed; private current-boot bindings; validate original boot/parent before translating only mount identities; historical retirement/legacy bytes preserved 4ecb75e5992c97d9d0445210689aa1f659f4c1fb8c8dd81ba4b84bd639f7fee7
+internal/host/claimservingreboot_binding.go servingParkBootBinding.directory authority C,J4 rev3=PROPOSED-seed; exact from-device/from-mount required before mapping both to independently committed current values; reverse mapping preserves historical identities 8df696c84f41f4ccbd77271b6a9ce6d0237d661f2db5fe1b40e9a0e0d5f98825
+internal/host/claimservingreboot_binding.go servingParkBootBinding.record authority C,J4 rev3=PROPOSED-seed; maps all directories, continuation authorizations and present artifacts through private reboot binding; reverse save preserves historical device, mount and boot e72d4c59265421e3cef5f31692029bee46f63638f8c72401422731e550e15763
+internal/host/claimservingreboot_binding.go servingParkLedger.bindRecord authority C,J4 rev3=PROPOSED-seed; private current-boot bindings; validate original boot/parent before translating boot-local device and mount identities; historical retirement/legacy bytes preserved e98b72dbd294f4a0b57bad42a2d6b3a429d56518db56dbdf36405529130aa513
+internal/host/claimservingreboot_binding.go servingParkReadBinding authority C,J4 rev3=PROPOSED-seed; private committed reboot binding; nonzero boot-local device/mount on both sides, unchanged parent inode and distinct boots; historical bytes unchanged file:Read@111 8a748537b12e8d72b42c07cda845509bb334a3552ae2735205f52e7d6fbafd9a
+internal/host/claimservingreboot_binding.go type servingParkBootBinding authority C,J4 rev3=PROPOSED-seed; private current-boot bindings; validate original boot/parent before translating boot-local device and mount identities; historical retirement/legacy bytes preserved 4ecb75e5992c97d9d0445210689aa1f659f4c1fb8c8dd81ba4b84bd639f7fee7
internal/host/claimservingreboot_commit_linux.go servingParkFS.readNamespace authority C,J4 rev3=PROPOSED-seed; fresh persistent handles, topology, slot and preparation; namespace then record binding; every unbound old-boot mutation refuses file:Read@114 d92accaa928f703132adef9aa1e81cae95a7bfa959c7a6fe5086189154a7c7f1
internal/host/claimservingreboot_commit_linux.go servingParkLedger.commitRebootLocked authority C,J4 rev3=PROPOSED-seed; fresh persistent handles, topology, slot and preparation; namespace then record binding; every unbound old-boot mutation refuses file:Read@246 d711de74b486ec757cbdac7aacb28e4414d47ba740082f8930857774b51d048a
internal/host/claimservingreboot_commit_linux.go servingParkRoot.commitServingReboot authority C,J4 rev3=PROPOSED-seed; fresh persistent handles, topology, slot and preparation; namespace then record binding; every unbound old-boot mutation refuses 6d511474a2112ea80f5457b0faa939da7144fb7bf72526e021e696ad90a0917b
internal/host/claimservingreboot_linux.go servingParkFS.inspectRebootRecord authority C,J4 rev3=PROPOSED-seed; repeated read-only per-retirement inspection under namespace lock; unchanged historical record and topology; result is not mutation or agent authority baacf7e1df1d8fd4a09a5f6204597ab51b8765feca9c614ddc74d863a3cf9055
-internal/host/claimservingreboot_linux.go servingParkFS.observeRebootRecord authority C,J4 rev3=PROPOSED-seed; immutable historical records and live persistent source/park identities; refuses inconsistent boot, mount, filesystem or ownership file:Readdirnames@2515 48a675389be8a8184e428c50971ddb8b7e8c33f8ceccef3703858fc307415053
-internal/host/claimservingreboot_linux.go servingParkFS.rebootDirectory authority C,J4 rev3=PROPOSED-seed; pinned descriptor device/inode, current mount and historical filesystem handle, owner and mode; no effects 2224be87510731990ca620abc0624a80675eca21228e561fa3e540d2ac8273c0
-internal/host/claimservingreboot_linux.go servingParkRebootFileMatches authority C,J4 rev3=PROPOSED-seed; historical file handle and current mount compared to source/event observations; lost rename and unlink replies require original progress; no writes 180ec916768fd2d012e689199963586dabbaa77553cea9d806882d1eacb15a75
+internal/host/claimservingreboot_linux.go servingParkFS.observeRebootRecord authority C,J4 rev3=PROPOSED-seed; repeated topology and immutable retirement read; source/event/artifact proof uses the independently observed current device and mount; no historical rewrite file:Readdirnames@2503 01bbe5c19696cae8a43e5a80c960c12b69b5fbc1defdfa597d432a6eb4df6682
+internal/host/claimservingreboot_linux.go servingParkFS.rebootDirectory authority C,J4 rev3=PROPOSED-seed; descriptor has historical inode, filesystem handle, owner and mode and current parent device/mount; no effects cc4eed6e9b2ff80f01f29c1933cc7238e01ac515ba85005c0c07a69234053df7
+internal/host/claimservingreboot_linux.go servingParkRebootFileMatches authority C,J4 rev3=PROPOSED-seed; historical artifact handle and inode plus current parent device/mount; preserves single-link, size, ownership and progress checks 73a12fe037864c8111b4fd91e4cba824d307f49a6cb0fcb2723141bb34dcdcbd
internal/host/claimservingreboot_linux.go type servingParkRebootRecord authority C,J4 rev3=PROPOSED-seed; read-only historical retirement and current source/event/namespace observations; has no effect or transport consumer 2752876f2e40f92a720c17c05e479dbfc7c4910392a1e6dc5ba4dc82b566763c
internal/host/claimservingreboot_prepare_linux.go servingParkLedger.observeRebootPreparation authority C,J4 rev3=PROPOSED-seed; persistent source/event/file inspection, original retirement, validated slot references and exact permanent legacy consumption; current installation binds incomplete records; malformed or stale evidence refuses 252a5c4463f979e5442585b66d06453e279f45ac17fe4f0ff993df7dee678da6
internal/host/claimservingreboot_prepare_linux.go servingParkLedger.prepareRebootLocked authority C,J4 rev3=PROPOSED-seed; historical retirement, fresh root installation, census, exact claim, stop and launch history; per-target-boot immutable preparation, repeated validation file:Read@625 a355b666f2aad99389f1c665a57a9b1603db5511538c68b64433bbde8beda0fb
@@ -274,7 +274,7 @@ internal/host/claimservingretire.go servingPredecessor authority C,J4 rev3=PROPO
internal/host/claimservingretire.go servingRetirementID authority C,J4 rev3=PROPOSED-seed; immutable predecessor and original successor tuples; deterministic journal identity independent of claim operation IDs c984974d17e621d7fe9c276a03ae789b86789abbccbb82a09d06f55c3bf13819
internal/host/claimservingretire.go servingWriterIdentity authority C,J4 rev3=PROPOSED-seed; authoritative serving holder and unique cold admission; exact local executor, stamp, session, generation, incarnation, location and region file:Exec@403 f7d8c868ce9856eab448aa72bdf20ee02cb70179ce0f435d45888f72e0c3b7bf
internal/host/claimservingtopology_linux.go servingParkFS.captureTopology authority C,J4 rev3=PROPOSED-seed; current boot and pinned parent/root descriptors; persistent filesystem identities, handles, owner and mode; rereads descriptor identities and boot before returning creation evidence 169ff4183b9d65770c49aeaa341ad061ee38105e8cdea4ccfb5ee3f57079ec20
-internal/host/claimservingtopology_linux.go servingParkFS.inspectRebootTopology authority C,J4 rev3=PROPOSED-seed; historical namespace parent and persistent filesystem handles; mount numbers alone never establish reboot identity file:Read@501,file:Read@956 d17c8bec59bd873dcf72479b066dd715dc1f329011af3f36e9c43d7291553549
+internal/host/claimservingtopology_linux.go servingParkFS.inspectRebootTopology authority C,J4 rev3=PROPOSED-seed; same persistent filesystem and parent/root handles and inodes across boots; current device/mount must agree; reopen names and recheck boot; read-only file:Read@501,file:Read@956 1e1584cd4a0456f050209e46029d6a58e973d7af39f47a47ada75f0927a24a96
internal/host/claimservingtopology_linux.go servingParkPersistentDir authority C,J4 rev3=PROPOSED-seed; descriptor-only Fstat, Fstatfs and NameToHandleAt with AT_EMPTY_PATH; bounded canonical handle and matching statx mount, mode and owner; no caller path, handle opening or permission widening file:Fstat@279,file:Fstatfs@375 2c60437509897dc3d7e861bf554a92cd5c338301b3bfbdee6b5d731ea9d3f7b8
internal/host/claimservingtopology_linux.go type servingParkRebootObservation authority C,J4 rev3=PROPOSED-seed; read-only old namespace and current boot/topology observation; no effect consumer or transport result accepts it e000b7d132c36a4979c5be443fc87996beafe2a8db320bf978ef1f026a09baa2
internal/host/claimservingtopology_linux.go type servingParkTopology authority C,J4 rev3=PROPOSED-seed; persistent parent/root evidence attached only when root creates and records a new namespace 16a7fce2850bf204ddd67aa10876979be08a5d500a21ea3072e2fc5c1c369586
@@ -325,7 +325,7 @@ system.go runSystem authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments,
system.go systemVerb authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins ee25513ed72f9b8338d95682c4c76b4ced26ec1f060528fc5804da9afbfbfd83
@coverage . bf3d70b6a2c5d769a3c580c9d5511231499b788d9c08a542895de8381e5a39c9
@coverage internal/activator 41f0ad01ac54bae63f089ef8ec5bbfa9155bb76720d72b2d88563481de40fe5c
-@coverage internal/host 8872d86cde27a46eec09390c6bf7a35dfd977e4afca9825f2a32b068f420f038
+@coverage internal/host dd140d90fe80775bf62b1b5a9992c1f399731781c9ec38afc237d285f9388425
@coverage internal/hostbox db890260533bdace6ad9a7c23dc1538cb919962894f005fc7c5a59a18e969429
@coverage internal/hostfiles a38dbcd9789ba4acd237e88c834f2b2bfca9847b0d43c32b0116dc8996d7a142
@coverage internal/hostguard a721142f4f3ac5fa17b6b1e2ccfc66ca809cfdcbf2733fdc00fbd0efaa90aeb6