carlos / platform Public

Plan 14 integrated: lifecycle E2E finds and fixes six more production defects (not for merge)

Initial route-only handover; credential directory outside root inventory; startup unit-state directories; empty bootstrap's own lease; stop recovery consuming handover barriers; verified executable's absolute name. Full host suite passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 4437d7008f7de6bf60f4cd77270e6ca8a0471c6d parent 204a78c
26 files changed, +555 −60
  • .claude/hooks +0 −0
  • .claude/launch.json +0 −0
  • .claude/loop.md +0 −0
  • .claude/output-styles +0 −0
  • .claude/routines +0 −0
  • .claude/settings.json +0 −0
  • .claude/workflows +0 −0
  • .mcp.json +0 −0
  • internal/host/claimempty_lease_test.go +53 −0
  • internal/host/claimowners.go +6 −2
  • internal/host/claimproduction.go +8 −1
  • internal/host/claimroleassembly_linux.go +14 −1
  • internal/host/claimroleassembly_linux_test.go +42 −0
  • internal/host/claimstop_recovery.go +1 −1
  • internal/host/claimtoken_inventory_linux.go +42 −0
  • internal/host/claimtoken_inventory_other.go +13 −0
  • internal/host/plan14_lifecycle_crash_root_linux_test.go +116 −0
  • internal/host/plan14_lifecycle_retire_root_linux_test.go +1 −1
  • internal/host/plan14_lifecycle_root_linux_test.go +152 −39
  • internal/host/rootjournal_namespace_root_linux_test.go +1 −1
  • internal/hostbox/journalnamespace_linux.go +1 −1
  • internal/hostbox/servinghandover.go +1 −1
  • internal/hostbox/servinghandover_linux_test.go +76 −6
  • internal/hostguard/plan14_root_fences_test.go +4 −0
  • internal/hostguard/recoveryfence.go +9 −1
  • internal/provision/testdata/plan14-root-reads-p3.tsv +15 −5

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	309.690s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.688s
ok  	github.com/carlosframework/platform/cmd/moveprobe	93.350s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.705s
ok  	github.com/carlosframework/platform/internal/activator	249.548s
ok  	github.com/carlosframework/platform/internal/agentsock	0.021s
ok  	github.com/carlosframework/platform/internal/alerting	0.005s
ok  	github.com/carlosframework/platform/internal/analytics	0.021s
ok  	github.com/carlosframework/platform/internal/appconfig	2.450s
ok  	github.com/carlosframework/platform/internal/apperr	0.077s
ok  	github.com/carlosframework/platform/internal/applog	0.143s
ok  	github.com/carlosframework/platform/internal/awsinv	0.004s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.005s
ok  	github.com/carlosframework/platform/internal/boxid	0.509s
?   	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.012s
ok  	github.com/carlosframework/platform/internal/certwarm	0.005s
ok  	github.com/carlosframework/platform/internal/cgroup	0.705s
ok  	github.com/carlosframework/platform/internal/changelog	0.305s
ok  	github.com/carlosframework/platform/internal/clamd	0.054s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	395.375s
?   	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.822s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.002s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.349s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	26.697s
?   	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.132s
ok  	github.com/carlosframework/platform/internal/gate	1.656s
ok  	github.com/carlosframework/platform/internal/genselect	0.015s
ok  	github.com/carlosframework/platform/internal/geo	0.020s
ok  	github.com/carlosframework/platform/internal/geoip	0.030s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.154s
ok  	github.com/carlosframework/platform/internal/host	172.481s
ok  	github.com/carlosframework/platform/internal/hostactors	0.466s
ok  	github.com/carlosframework/platform/internal/hostbox	2.898s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.563s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.014s
ok  	github.com/carlosframework/platform/internal/hostexec	5.015s
ok  	github.com/carlosframework/platform/internal/hostfence	0.029s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.461s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.014s
ok  	github.com/carlosframework/platform/internal/hostguard	1.016s
ok  	github.com/carlosframework/platform/internal/hostops	1.608s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.014s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.014s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.471s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.151s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.014s
ok  	github.com/carlosframework/platform/internal/infraview	0.013s
ok  	github.com/carlosframework/platform/internal/instancectl	5.231s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.790s
ok  	github.com/carlosframework/platform/internal/instancefs	0.018s
ok  	github.com/carlosframework/platform/internal/instancekey	0.013s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.019s
ok  	github.com/carlosframework/platform/internal/layout	1.563s
ok  	github.com/carlosframework/platform/internal/lease	9.867s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.018s
ok  	github.com/carlosframework/platform/internal/ledger	0.010s
ok  	github.com/carlosframework/platform/internal/mail	0.178s
ok  	github.com/carlosframework/platform/internal/maintenance	0.082s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.512s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.074s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	21.146s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.830s
ok  	github.com/carlosframework/platform/internal/objstore	0.218s
ok  	github.com/carlosframework/platform/internal/ops	0.177s
ok  	github.com/carlosframework/platform/internal/park	0.213s
ok  	github.com/carlosframework/platform/internal/privatefile	0.008s
ok  	github.com/carlosframework/platform/internal/provision	36.096s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.008s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.485s
ok  	github.com/carlosframework/platform/internal/relay	7.244s
ok  	github.com/carlosframework/platform/internal/release	21.689s
ok  	github.com/carlosframework/platform/internal/rollout	3.792s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.004s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.057s
ok  	github.com/carlosframework/platform/internal/rootstate	0.026s
ok  	github.com/carlosframework/platform/internal/runs	0.588s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.509s
ok  	github.com/carlosframework/platform/internal/seeds	0.014s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.689s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.609s
ok  	github.com/carlosframework/platform/internal/status	0.039s
ok  	github.com/carlosframework/platform/internal/steering	0.005s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.316s
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.007s
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.003s
ok  	github.com/carlosframework/platform/internal/vet	1.591s
=== 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: 48496802 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.022s
=== RUN   TestT4StorageProbeMinio
2026/10/16 17:12:07 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DF11C81B7D0D10, 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/.claude/hooks b/.claude/hooks
new file mode 100644
index 000000000..e69de29bb
diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 000000000..e69de29bb
diff --git a/.claude/loop.md b/.claude/loop.md
new file mode 100644
index 000000000..e69de29bb
diff --git a/.claude/output-styles b/.claude/output-styles
new file mode 100644
index 000000000..e69de29bb
diff --git a/.claude/routines b/.claude/routines
new file mode 100644
index 000000000..e69de29bb
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 000000000..e69de29bb
diff --git a/.claude/workflows b/.claude/workflows
new file mode 100644
index 000000000..e69de29bb
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 000000000..e69de29bb
diff --git a/internal/host/claimempty_lease_test.go b/internal/host/claimempty_lease_test.go
new file mode 100644
index 000000000..b61158b2c
--- /dev/null
+++ b/internal/host/claimempty_lease_test.go
@@ -0,0 +1,53 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/lease/leasetest"
+)
+
+func TestClaimEmptyBootstrapWithRealLease(t *testing.T) {
+ f := plan9D5Fixture(t)
+ handler, _ := leasetest.NewS3Server(f.bucket)
+ server := httptest.NewServer(handler)
+ defer server.Close()
+ for k, v := range map[string]string{"AWS_ACCESS_KEY_ID": "test-key", "AWS_SECRET_ACCESS_KEY": "test-secret", "AWS_SESSION_TOKEN": "", "AWS_EC2_METADATA_DISABLED": "true", "AWS_CONFIG_FILE": "/nonexistent", "AWS_SHARED_CREDENTIALS_FILE": "/nonexistent", "CARLOS_S3_ENDPOINT": server.URL} {
+ t.Setenv(k, v)
+ }
+ created := plan9D5Observed(hostclaim.Holder{Stamp: 3})
+ in, err := f.owners.Select(t.Context(), plan9D5Route, created, activator.StartPolicy{MaxStarts: 5, Window: 30 * time.Second})
+ if err != nil {
+ t.Fatal(err)
+ }
+ held, err := lease.Acquire(t.Context(), "data", created.Claim.Holder.Location, "eu-west-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ if err := held.Release(context.Background()); err != nil {
+ t.Error(err)
+ }
+ }()
+ admitted := plan9D5Observed(hostclaim.Holder{Stamp: 5, Session: 5, Generation: in.Admission.NewGeneration, WriterBox: "box-1", Admissions: []hostclaim.AdmissionRecord{{Session: 5, Box: "box-1", Generation: in.Admission.NewGeneration, Region: "ie", Mode: "cold"}}})
+ if err := f.owners.EmptyAuthority(t.Context(), admitted, in); err != nil {
+ t.Fatal("first writer holding its lease", err)
+ }
+ for _, suffix := range []string{"generation.json", "g-foreign/lock.json", "lock.json/child", "lock.json.old"} {
+ key := created.Claim.Holder.Location + "/" + suffix
+ id := f.bucket.Put("data", key, []byte("foreign"))
+ if err := f.owners.EmptyAuthority(t.Context(), admitted, in); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("accepted %s: %v", suffix, err)
+ }
+ if err := f.bucket.DeleteVersion(t.Context(), "data", key, id); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
diff --git a/internal/host/claimowners.go b/internal/host/claimowners.go
index e7ae8dfa6..29d7edee0 100644
--- a/internal/host/claimowners.go
+++ b/internal/host/claimowners.go
@@ -12,6 +12,7 @@ import (
"sync"
"time"
+ lss3 "github.com/benbjohnson/litestream/s3"
"github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/cgroup"
"github.com/carlosframework/platform/internal/genselect"
@@ -497,8 +498,11 @@ func (s *claimServingOwners) EmptyAuthority(ctx context.Context, o hostclaim.Obs
if err != nil {
return err
}
- if len(versions) != 0 {
- return hostbox.ErrChanged
+ leaseKey := strings.TrimSuffix(h.Location, "/") + "/" + lss3.DefaultLeasePath
+ for _, version := range versions {
+ if version.Key != leaseKey {
+ return hostbox.ErrChanged
+ }
}
return nil
}
diff --git a/internal/host/claimproduction.go b/internal/host/claimproduction.go
index dc703ccef..876dba8dc 100644
--- a/internal/host/claimproduction.go
+++ b/internal/host/claimproduction.go
@@ -547,7 +547,14 @@ func claimServingLaunchSource(backend *activator.ExecBackend, tokens claimAdminT
return claimBoundSnapshot(ctx, backend, rt, func(context.Context, registry.Route) (*hostbox.UnitLaunchInputs, error) { return unit, nil })
}
}
- if err := ensureClaimLaunchToken(ctx, backend, rt, func(ctx context.Context, effect func() error) error { return tokens.With(ctx, rt.Host, effect) }); err != nil {
+ if err := ensureClaimLaunchToken(ctx, backend, rt, func(ctx context.Context, effect func() error) error {
+ if layout, _ := backend.Layout(rt); layout == activator.LayoutNested {
+ if err := tokens.prepareState(ctx, rt); err != nil {
+ return err
+ }
+ }
+ return tokens.With(ctx, rt.Host, effect)
+ }); err != nil {
return ClaimServingLaunch{}, err
}
snapshot, err := backend.SelectClaimLaunch(ctx, rt)
diff --git a/internal/host/claimroleassembly_linux.go b/internal/host/claimroleassembly_linux.go
index 92462c5c4..143887817 100644
--- a/internal/host/claimroleassembly_linux.go
+++ b/internal/host/claimroleassembly_linux.go
@@ -311,7 +311,20 @@ func verifiedClaimExecutable(ctx context.Context, cmd *exec.Cmd, path, digest st
if _, err = f.Seek(0, 0); err != nil {
return refuse(err)
}
- return f, nil
+ return claimExecutableName(f, path)
+}
+
+func claimExecutableName(f *os.File, path string) (*os.File, error) {
+ namedFD, err := unix.FcntlInt(f.Fd(), unix.F_DUPFD_CLOEXEC, 0)
+ if err != nil {
+ f.Close()
+ return nil, err
+ }
+ if err = f.Close(); err != nil {
+ unix.Close(namedFD)
+ return nil, err
+ }
+ return os.NewFile(uintptr(namedFD), path), nil
}
func claimReadyPeer(conn net.Conn, pid int) error {
diff --git a/internal/host/claimroleassembly_linux_test.go b/internal/host/claimroleassembly_linux_test.go
index 1bad58bed..d3a2e0816 100644
--- a/internal/host/claimroleassembly_linux_test.go
+++ b/internal/host/claimroleassembly_linux_test.go
@@ -6,6 +6,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
+ "github.com/carlosframework/platform/internal/safefile"
"io"
"os"
"os/exec"
@@ -83,6 +84,9 @@ func TestClaimExecutableAcceptsRootOwnedSystemBinary(t *testing.T) {
t.Fatal(err)
}
defer f.Close()
+ if f.Name() != path {
+ t.Fatalf("verified executable name %q, want %q", f.Name(), path)
+ }
got, err := io.ReadAll(f)
if err != nil || string(got) != string(body) {
t.Fatalf("pinned system executable differs: %v", err)
@@ -150,3 +154,41 @@ func TestClaimExecutableRefusesHardLinkAndSymlinkParent(t *testing.T) {
})
}
}
+
+func TestClaimExecutableNameKeepsPinnedFile(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "program")
+ if err := os.WriteFile(path, []byte("verified bytes"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ f, err := safefile.OpenFile(path, os.O_RDONLY, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { f.Close() })
+ before, err := f.Stat()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Rename(path, path+".old"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("replacement"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ named, err := claimExecutableName(f, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer named.Close()
+ after, err := named.Stat()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if named.Name() != path || !os.SameFile(before, after) {
+ t.Fatal("named executable lost its identity", named.Name(), path)
+ }
+ body, err := io.ReadAll(named)
+ if err != nil || string(body) != "verified bytes" {
+ t.Fatal(string(body), err)
+ }
+}
diff --git a/internal/host/claimstop_recovery.go b/internal/host/claimstop_recovery.go
index da646b4eb..bca0d2ff3 100644
--- a/internal/host/claimstop_recovery.go
+++ b/internal/host/claimstop_recovery.go
@@ -65,7 +65,7 @@ func (c ClaimStopCoordinator) RecoverStopped(ctx context.Context, target hostgua
return StopResult{Target: target, Key: stopEvidenceKey(target.Host, target.Op, target.Box, id), Digest: digestRawStop(body), Bytes: body, EvidenceAt: at}, nil
}
func (h ClaimStopHTTP) RecoverPending(ctx context.Context) error {
- targets, err := h.Coordinator.Guard.PendingFences(ctx)
+ targets, err := h.Coordinator.Guard.PendingStopFences(ctx)
if err != nil {
return err
}
diff --git a/internal/host/claimtoken_inventory_linux.go b/internal/host/claimtoken_inventory_linux.go
new file mode 100644
index 000000000..8a3753e99
--- /dev/null
+++ b/internal/host/claimtoken_inventory_linux.go
@@ -0,0 +1,42 @@
+//go:build linux
+
+package host
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/controlsock"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+type claimTokenWork struct {
+ tokens claimAdminTokens
+ host string
+}
+
+func (w claimTokenWork) With(ctx context.Context, _ hostwrite.Identity, effect func() error) error {
+ return w.tokens.With(ctx, w.host, effect)
+}
+
+func (t claimAdminTokens) prepareState(ctx context.Context, route registry.Route) error {
+ if route.DBPath != provision.NestedDBPath(t.Guard.DataDir, route.Host) {
+ return hostbox.ErrChanged
+ }
+ call := rootJournalClient(controlsock.Path)
+ _, inventory := hostbox.RemoteJournals(call)
+ defer inventory.Close()
+ t.Guard.FenceView = rootFenceViewClient(call)
+ observed, err := hostclaim.Read(ctx, t.Claims, route.Host)
+ if err != nil {
+ return err
+ }
+ actor, err := t.Verify(ctx)
+ if err != nil {
+ return err
+ }
+ a := &ClaimRoleAssembly{Claims: t.Claims, Guard: t.Guard, Inventory: inventory, ManagedRoots: map[string]string{"data": t.Guard.DataDir}}
+ return a.prepareStateRemote(ctx, observed, claimTokenWork{t, route.Host}, ClaimLaunchInputs{DBPath: route.DBPath}, actor)
+}
diff --git a/internal/host/claimtoken_inventory_other.go b/internal/host/claimtoken_inventory_other.go
new file mode 100644
index 000000000..f2197095a
--- /dev/null
+++ b/internal/host/claimtoken_inventory_other.go
@@ -0,0 +1,13 @@
+//go:build !linux
+
+package host
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func (t claimAdminTokens) prepareState(context.Context, registry.Route) error {
+ return hostbox.ErrUnsupported
+}
diff --git a/internal/host/plan14_lifecycle_crash_root_linux_test.go b/internal/host/plan14_lifecycle_crash_root_linux_test.go
new file mode 100644
index 000000000..78aa901ba
--- /dev/null
+++ b/internal/host/plan14_lifecycle_crash_root_linux_test.go
@@ -0,0 +1,116 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "os/exec"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/controlsock"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+const lifecycleCrashPath = "/var/lib/carlos-claims/lifecycle-crash"
+
+func lifecycleCrash(t *testing.T, point string) {
+ t.Helper()
+ if os.Getenv("PLAN14_LIFECYCLE_CRASH") != point {
+ return
+ }
+ if _, err := os.Stat(lifecycleCrashPath); err == nil {
+ return
+ } else if !errors.Is(err, os.ErrNotExist) {
+ t.Fatal(err)
+ }
+ state, err := rootstate.Open("/var/lib/carlos-claims", false, false)
+ lifecycleCheck(t, "crash checkpoint store", err)
+ lifecycleCheck(t, "durable crash checkpoint", state.Write("lifecycle-crash", []byte(point)))
+ os.Exit(86)
+}
+
+type lifecycleCrashStore struct {
+ rootstate.Store
+ t *testing.T
+}
+
+func (s lifecycleCrashStore) Write(name string, body []byte) error {
+ if err := s.Store.Write(name, body); err != nil {
+ return err
+ }
+ if name != lifecycleHost+".json" {
+ return nil
+ }
+ var r struct {
+ Installation hostguard.RootInstallation
+ Retired []hostguard.Captured
+ Barrier *hostguard.FenceTarget
+ State struct {
+ ActiveStamp uint64 `json:"active_stamp"`
+ }
+ }
+ if err := json.Unmarshal(body, &r); err != nil {
+ return err
+ }
+ if r.Barrier != nil {
+ lifecycleCrash(s.t, "handover")
+ }
+ if r.State.ActiveStamp == 0 && r.Installation.Stamp != 0 && len(r.Retired) != 0 {
+ lifecycleCrash(s.t, "retirement")
+ }
+ return nil
+}
+
+func lifecycleControlFaults(t *testing.T) {
+ original := openClaimRootAuthority
+ openClaimRootAuthority = func() (rootstate.Store, error) {
+ s, err := original()
+ if err != nil {
+ return nil, err
+ }
+ return lifecycleCrashStore{s, t}, nil
+ }
+ t.Cleanup(func() { openClaimRootAuthority = original })
+}
+
+func lifecycleStartControl(t *testing.T, endpoint string) func() {
+ t.Helper()
+ log, err := os.OpenFile(lifecycleHome+"/controller.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
+ lifecycleCheck(t, "controller log", err)
+ var cmd *exec.Cmd
+ start := func() {
+ if cmd != nil {
+ cmd.Process.Kill()
+ cmd.Wait()
+ }
+ os.Remove("/run/carlos-control/lifecycle-ready")
+ os.Remove(controlsock.Path)
+ cmd = exec.Command("/opt/carlos/carlos", "-test.run=^TestPlan14LifecycleControlProcess$", "-test.v", "-test.timeout=8m")
+ cmd.Env = append(os.Environ(), "PLAN14_LIFECYCLE_CONTROL=1", "CARLOS_S3_ENDPOINT="+endpoint)
+ cmd.Stdout, cmd.Stderr = log, log
+ lifecycleCheck(t, "start controller", cmd.Start())
+ deadline := time.Now().Add(time.Minute)
+ for time.Now().Before(deadline) {
+ if _, err := os.Stat("/run/carlos-control/lifecycle-ready"); err == nil {
+ return
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ t.Fatal("controller startup timeout")
+ }
+ t.Cleanup(func() {
+ if cmd != nil {
+ cmd.Process.Kill()
+ cmd.Wait()
+ }
+ log.Close()
+ body, _ := os.ReadFile(lifecycleHome + "/controller.log")
+ t.Logf("controller log: %s", body)
+ })
+ start()
+ return start
+}
diff --git a/internal/host/plan14_lifecycle_retire_root_linux_test.go b/internal/host/plan14_lifecycle_retire_root_linux_test.go
index 27b16d3d4..198b32659 100644
--- a/internal/host/plan14_lifecycle_retire_root_linux_test.go
+++ b/internal/host/plan14_lifecycle_retire_root_linux_test.go
@@ -29,7 +29,7 @@ func lifecycleDeleteDriver(t *testing.T, phase string, raw release.Store, key ed
ctx := t.Context()
serial := 0
return func() {
- if phase != "2" {
+ if phase != "2" && phase != "crash" {
t.Fatal("unexpected deletion before reboot")
}
if operation == nil {
diff --git a/internal/host/plan14_lifecycle_root_linux_test.go b/internal/host/plan14_lifecycle_root_linux_test.go
index a9c4f1a5b..2ea82b32d 100644
--- a/internal/host/plan14_lifecycle_root_linux_test.go
+++ b/internal/host/plan14_lifecycle_root_linux_test.go
@@ -34,6 +34,7 @@ import (
"github.com/carlosframework/platform/internal/hostfloor"
"github.com/carlosframework/platform/internal/hostguard"
"github.com/carlosframework/platform/internal/hostops"
+ "github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/instancectl"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/lease/leasetest"
@@ -178,7 +179,16 @@ func TestPlan14LifecycleEndToEndRootRunner(t *testing.T) {
if os.Getenv("PLAN14_LIFECYCLE_PHASE") == "" {
t.Fatal("lifecycle E2E requires root-runner --reboot Plan14LifecycleEndToEnd")
}
- lifecycleRun(t)
+ t.Run("ordinary", lifecycleRun)
+ if os.Getenv("PLAN14_LIFECYCLE_PHASE") == "2" && os.Getenv("PLAN14_LIFECYCLE_AGENT") == "" {
+ for _, point := range []string{"reservation", "handover", "retirement"} {
+ t.Run("crash-"+point, func(t *testing.T) {
+ t.Setenv("PLAN14_LIFECYCLE_PHASE", "crash")
+ t.Setenv("PLAN14_LIFECYCLE_CRASH", point)
+ lifecycleRun(t)
+ })
+ }
+ }
}
func lifecycleRun(t *testing.T) {
@@ -186,6 +196,7 @@ func lifecycleRun(t *testing.T) {
lifecycleAgent(t)
return
}
+ TestPlan14RootJournalNamespaceRootRunner(t)
phase := os.Getenv("PLAN14_LIFECYCLE_PHASE")
resume := phase == "2"
uid, gid := generationRunnerRegistryUser(t)
@@ -288,51 +299,33 @@ func lifecycleRun(t *testing.T) {
}
return os.Chown(path, uid, gid)
}))
- reg, err := openServiceRegistry("/data/registry.db")
- lifecycleCheck(t, "registry", err)
- t.Cleanup(func() { reg.Close() })
- resources := ClaimRuntimeResources{Mode: "instance-control", Registry: reg, DataDir: "/data", OptDir: "/opt/carlos", UnitsDir: "/etc/systemd/system"}
- ctx := context.WithValue(t.Context(), claimEntryKey{}, claimEntry{Enabled: true, FlagRead: true, Store: store, Resources: resources})
- assembly, err := OpenProductionClaimAssembly(ctx, store, resources, lifecycleOwners{t})
- lifecycleCheck(t, "root assembly", err)
- t.Cleanup(func() { assembly.Close(context.Background()) })
- ctx = WithClaimAssembly(ctx, assembly)
- lifecycleCheck(t, "launch verifier", registerProductionLaunchVerifier())
- root, err := NewProductionRootClaimStop(ctx)
- lifecycleCheck(t, "root constructor", err)
- wrapped, err := WithProductionClaimLaunch(ctx, root)
- lifecycleCheck(t, "launch constructor", err)
- rootHTTP := wrapped.(ClaimRootWithLaunch)
- t.Cleanup(func() { rootHTTP.Root.executionRuntime.Close(context.Background()) })
- listener, err := net.Listen("unix", controlsock.Path)
- lifecycleCheck(t, "root socket", err)
- lifecycleCheck(t, "root socket mode", os.Chmod(controlsock.Path, 0660))
- lifecycleCheck(t, "root socket group", os.Chown(controlsock.Path, 0, gid))
- controlCtx, cancelControl := context.WithCancel(ctx)
- defer cancelControl()
- control := &instancectl.Server{ClaimStop: wrapped, PeerOK: func(c net.Conn) error { _, err := rootHTTP.Root.Stop.Authenticate(ctx, c); return err }, Logf: t.Logf}
- done := make(chan error, 1)
- go func() { done <- control.Serve(controlCtx, listener) }()
- defer func() { cancelControl(); lifecycleCheck(t, "control shutdown", <-done) }()
- lifecycleCheck(t, "root recovery", rootHTTP.RecoverPending(ctx))
- rootHTTP.Start(ctx)
+ ctx := t.Context()
lifecycleInstallUnits(t)
+ out, mountErr := exec.Command("systemctl", "daemon-reload").CombinedOutput()
+ lifecycleCheck(t, string(out), mountErr)
+ out, mountErr = exec.Command("systemctl", "start", "data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount").CombinedOutput()
+ lifecycleCheck(t, string(out), mountErr)
+ recoverControl := lifecycleStartControl(t, server.URL)
lifecycleStartAgent(t, server.URL, completeCreate, func() {
_, _, err = release.StageSignedHead(ctx, store, key.Public().(ed25519.PublicKey), nil, "/opt/carlos", registry.Route{Host: rec.Host, Account: rec.Account, App: rec.App, Channel: "stable"})
lifecycleCheck(t, "signed stage", err)
lifecycleCheck(t, "point initial version", release.PointLive("/opt/carlos", rec.Host, rec.Account, "v1", rec.App))
- }, lifecycleDeleteDriver(t, phase, store, key, actor, pointer))
+ }, lifecycleDeleteDriver(t, phase, store, key, actor, pointer), recoverControl)
if phase == "1" {
lifecycleSaveBucket(t, model)
}
- if phase == "2" {
+ if phase == "2" || phase == "crash" {
lifecycleVerifyRetiredPins(t, store, key)
- t.Fatal("lifecycle crash recovery checkpoints remain required")
+ if phase == "crash" {
+ if _, err := os.Stat(lifecycleCrashPath); err != nil {
+ t.Fatal("crash point was not reached", err)
+ }
+ }
}
}
-func lifecycleStartAgent(t *testing.T, endpoint string, completeCreate func() error, stage func(), deletion func()) {
+func lifecycleStartAgent(t *testing.T, endpoint string, completeCreate func() error, stage func(), deletion func(), recoverControl func()) {
t.Helper()
unit, err := unitsFS.ReadFile("units/carlos-edge.service")
lifecycleCheck(t, "edge template", err)
@@ -362,8 +355,28 @@ func lifecycleStartAgent(t *testing.T, endpoint string, completeCreate func() er
out, err = exec.Command("systemctl", "start", "carlos-edge.service").CombinedOutput()
lifecycleCheck(t, string(out), err)
deadline := time.Now().Add(3 * time.Minute)
- staged, created := false, false
+ staged, created, recovered := false, false, false
for time.Now().Before(deadline) {
+ if _, err := os.Stat(lifecycleCrashPath); err == nil && !recovered {
+ recoverControl()
+ until := time.Now().Add(40 * time.Second)
+ for {
+ state, _ := exec.Command("systemctl", "show", "carlos-edge.service", "-p", "ActiveState", "--value").Output()
+ if strings.TrimSpace(string(state)) == "failed" || strings.TrimSpace(string(state)) == "inactive" {
+ break
+ }
+ if time.Now().After(until) {
+ t.Fatal("edge did not finish cleanup after controller crash")
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ os.Remove("/run/carlos/lifecycle-result")
+ recoverControl()
+ out, err := exec.Command("systemctl", "start", "carlos-edge.service").CombinedOutput()
+ lifecycleCheck(t, string(out), err)
+ recovered = true
+ t.Log("controller and edge restarted after", os.Getenv("PLAN14_LIFECYCLE_CRASH"))
+ }
if !created {
err := completeCreate()
if err == nil {
@@ -389,6 +402,8 @@ func lifecycleStartAgent(t *testing.T, endpoint string, completeCreate func() er
log, _ := exec.Command("journalctl", "-u", "carlos-edge.service", "--no-pager", "-n", "100").CombinedOutput()
t.Fatalf("%s: %s", raw, log)
}
+ log, _ := exec.Command("journalctl", "-u", "carlos-edge.service", "--no-pager", "-n", "100").CombinedOutput()
+ t.Logf("completed agent: %s", log)
return
}
status, _ := exec.Command("systemctl", "show", "carlos-edge.service", "-p", "ActiveState", "--value").Output()
@@ -403,10 +418,11 @@ func lifecycleStartAgent(t *testing.T, endpoint string, completeCreate func() er
func lifecycleAgent(t *testing.T) {
lifecycleEnvironment(t, os.Getenv("CARLOS_S3_ENDPOINT"))
+ completed := false
defer func() {
- result := "pass"
- if t.Failed() {
- result = "agent failed; inspect journal"
+ result := "agent failed; inspect journal"
+ if completed && !t.Failed() {
+ result = "pass"
}
os.WriteFile("/run/carlos/lifecycle-result", []byte(result), 0600)
}()
@@ -419,9 +435,23 @@ func lifecycleAgent(t *testing.T) {
ctx := context.WithValue(t.Context(), claimEntryKey{}, claimEntry{Enabled: true, FlagRead: true, Store: store, Resources: resources})
assembly, err := OpenProductionClaimAssembly(ctx, store, resources, lifecycleOwners{t})
lifecycleCheck(t, "agent assembly", err)
- defer assembly.Close(context.Background())
+ defer func() {
+ deadline := time.Now().Add(30 * time.Second)
+ for {
+ err := assembly.Close(context.Background())
+ if err == nil {
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Error("agent cleanup", err)
+ return
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ }()
providers, err := assembly.Build(ctx)
lifecycleCheck(t, "agent build", err)
+ lifecycleTraceWake(t, assembly.Wake)
deadline := time.Now().Add(time.Minute)
for {
if _, err := os.Stat("/run/carlos/lifecycle-created"); err == nil {
@@ -464,7 +494,7 @@ func lifecycleAgent(t *testing.T) {
lifecycleWake(t, ctx, providers.Wake, assembly.Wake, *route)
t.Logf("completed cold wake %d", wake+1)
}
- if os.Getenv("PLAN14_LIFECYCLE_PHASE") == "2" {
+ if os.Getenv("PLAN14_LIFECYCLE_PHASE") == "2" || os.Getenv("PLAN14_LIFECYCLE_PHASE") == "crash" {
lifecycleCheck(t, "request deletion", os.WriteFile("/run/carlos/lifecycle-delete", []byte("ready"), 0600))
deadline := time.Now().Add(time.Minute)
for time.Now().Before(deadline) {
@@ -477,12 +507,14 @@ func lifecycleAgent(t *testing.T) {
obs, e := hostclaim.Read(ctx, store, lifecycleHost)
lifecycleCheck(t, "retirement observation", e)
if obs.Claim.Free() {
+ completed = true
return
}
time.Sleep(100 * time.Millisecond)
}
t.Fatal("retirement did not complete")
}
+ completed = true
}
func lifecycleWake(t *testing.T, ctx context.Context, runtime *activator.ClaimRuntime, wake *ClaimWakeAssembly, route registry.Route) {
@@ -677,3 +709,84 @@ func lifecycleController(t *testing.T, raw release.Store, key ed25519.PrivateKey
}}
return controller, authority, control
}
+
+func TestPlan14LifecycleControlProcess(t *testing.T) {
+ if os.Getenv("PLAN14_LIFECYCLE_CONTROL") != "1" {
+ t.Fatal("controller helper")
+ }
+ lifecycleEnvironment(t, os.Getenv("CARLOS_S3_ENDPOINT"))
+ _, gid := generationRunnerRegistryUser(t)
+ store := release.NewDirStore(lifecycleHome + "/deployment")
+ lifecycleControlFaults(t)
+ reg, err := openServiceRegistry("/data/registry.db")
+ lifecycleCheck(t, "registry", err)
+ t.Cleanup(func() { reg.Close() })
+ resources := ClaimRuntimeResources{Mode: "instance-control", Registry: reg, DataDir: "/data", OptDir: "/opt/carlos", UnitsDir: "/etc/systemd/system"}
+ ctx := context.WithValue(t.Context(), claimEntryKey{}, claimEntry{Enabled: true, FlagRead: true, Store: store, Resources: resources})
+ assembly, err := OpenProductionClaimAssembly(ctx, store, resources, lifecycleOwners{t})
+ lifecycleCheck(t, "root assembly", err)
+ t.Cleanup(func() { assembly.Close(context.Background()) })
+ ctx = WithClaimAssembly(ctx, assembly)
+ lifecycleCheck(t, "launch verifier", registerProductionLaunchVerifier())
+ root, err := NewProductionRootClaimStop(ctx)
+ lifecycleCheck(t, "root constructor", err)
+ wrapped, err := WithProductionClaimLaunch(ctx, root)
+ lifecycleCheck(t, "launch constructor", err)
+ rootHTTP := wrapped.(ClaimRootWithLaunch)
+ begin := rootHTTP.Launch.generations.beginUpdate
+ rootHTTP.Launch.generations.beginUpdate = func(ctx context.Context, s *rootGenerationSource, b release.GenerationBinding) (func(string, error, func() error) error, func() error, error) {
+ lifecycleCrash(t, "reservation")
+ return begin(ctx, s, b)
+ }
+ t.Cleanup(func() { rootHTTP.Root.executionRuntime.Close(context.Background()) })
+ listener, err := net.Listen("unix", controlsock.Path)
+ lifecycleCheck(t, "root socket", err)
+ lifecycleCheck(t, "root socket mode", os.Chmod(controlsock.Path, 0660))
+ lifecycleCheck(t, "root socket group", os.Chown(controlsock.Path, 0, gid))
+ controlCtx, cancelControl := context.WithCancel(ctx)
+ defer cancelControl()
+ control := &instancectl.Server{ClaimStop: rootHTTP, PeerOK: func(c net.Conn) error { _, err := rootHTTP.Root.Stop.Authenticate(ctx, c); return err }, Logf: t.Logf}
+ done := make(chan error, 1)
+ go func() { done <- control.Serve(controlCtx, listener) }()
+ defer func() { cancelControl(); lifecycleCheck(t, "control shutdown", <-done) }()
+ lifecycleCheck(t, "root recovery", rootHTTP.RecoverPending(ctx))
+ rootHTTP.Start(ctx)
+
+ lifecycleCheck(t, "controller ready", os.WriteFile("/run/carlos-control/lifecycle-ready", []byte("ready"), 0600))
+ <-ctx.Done()
+}
+
+func lifecycleTraceWake(t *testing.T, w *ClaimWakeAssembly) {
+ recover := w.RecoverArtifacts
+ if recover != nil {
+ w.RecoverArtifacts = func(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs) error {
+ e := recover(ctx, o, in)
+ t.Log("recover artifacts", e)
+ return e
+ }
+ }
+ prepare := w.PrepareState
+ if prepare != nil {
+ w.PrepareState = func(ctx context.Context, o hostclaim.Observed, work hostwrite.Work, in ClaimLaunchInputs) error {
+ e := prepare(ctx, o, work, in)
+ t.Log("prepare state", e)
+ return e
+ }
+ }
+ before := w.BeforeLegacyInstall
+ if before != nil {
+ w.BeforeLegacyInstall = func(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, work hostwrite.Work) error {
+ e := before(ctx, o, in, work)
+ t.Logf("legacy install empty=%v admission=%+v error=%v", in.Empty, in.Admission, e)
+ return e
+ }
+ }
+ enroll := w.BeforeInstallLocked
+ if enroll != nil {
+ w.BeforeInstallLocked = func(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs) error {
+ e := enroll(ctx, o, in)
+ t.Log("enroll", e)
+ return e
+ }
+ }
+}
diff --git a/internal/host/rootjournal_namespace_root_linux_test.go b/internal/host/rootjournal_namespace_root_linux_test.go
index 7a8079792..ec935adbe 100644
--- a/internal/host/rootjournal_namespace_root_linux_test.go
+++ b/internal/host/rootjournal_namespace_root_linux_test.go
@@ -15,7 +15,7 @@ func TestPlan14RootJournalNamespaceRootRunner(t *testing.T) {
if err := os.Chmod(dir, 0700); err != nil {
t.Fatal(err)
}
- for _, name := range []string{"authority", "held-release", "retire", "migration-snapshot"} {
+ for _, name := range []string{"authority", "held-release", "unit-bindings", "unit-materializations", "retire", "migration-snapshot"} {
path := filepath.Join(dir, name)
if err := os.Mkdir(path, 0700); err != nil {
t.Fatal(err)
diff --git a/internal/hostbox/journalnamespace_linux.go b/internal/hostbox/journalnamespace_linux.go
index 812b88bd2..1c80ab143 100644
--- a/internal/hostbox/journalnamespace_linux.go
+++ b/internal/hostbox/journalnamespace_linux.go
@@ -67,7 +67,7 @@ func prepareJournalDirectory(path string, uid uint32) error {
if err = unix.Fstatat(fd, entry.Name(), &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
return err
}
- if st.Uid == uid && st.Mode&unix.S_IFMT == unix.S_IFDIR && st.Mode&0077 == 0 && (strings.HasPrefix(entry.Name(), "migration-") || entry.Name() == "retire" || entry.Name() == "authority" || entry.Name() == "held-release") {
+ if st.Uid == uid && st.Mode&unix.S_IFMT == unix.S_IFDIR && st.Mode&0077 == 0 && (strings.HasPrefix(entry.Name(), "migration-") || entry.Name() == "retire" || entry.Name() == "authority" || entry.Name() == "held-release" || entry.Name() == "unit-bindings" || entry.Name() == "unit-materializations") {
continue
}
if st.Uid != uid || st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 {
diff --git a/internal/hostbox/servinghandover.go b/internal/hostbox/servinghandover.go
index c9a0db041..2d787e691 100644
--- a/internal/hostbox/servinghandover.go
+++ b/internal/hostbox/servinghandover.go
@@ -240,7 +240,7 @@ func (h ServingHandover) validate(ctx context.Context, j coldJournal, c *hostcla
return ErrTypedInventory
}
}
- if routes != 1 || paths != 1 || timers != len(j.Before.Timers) {
+ if routes != 1 || paths > 1 || (paths == 0 && p.Session != 0) || timers != len(j.Before.Timers) {
return ErrTypedInventory
}
return h.Validate(ctx, c, p, n, j.Inventory, *j.ServingInstallation)
diff --git a/internal/hostbox/servinghandover_linux_test.go b/internal/hostbox/servinghandover_linux_test.go
index 4a2ceb235..e43d6bf68 100644
--- a/internal/hostbox/servinghandover_linux_test.go
+++ b/internal/hostbox/servinghandover_linux_test.go
@@ -20,6 +20,11 @@ import (
)
func servingHandoverFixture(t *testing.T, deviceDelta ...uint64) (ServingHandover, hostguard.FenceTarget, ColdSuccessor) {
+ t.Helper()
+ return servingHandoverInitialFixture(t, false, false, deviceDelta...)
+}
+
+func servingHandoverInitialFixture(t *testing.T, first, omitPath bool, deviceDelta ...uint64) (ServingHandover, hostguard.FenceTarget, ColdSuccessor) {
t.Helper()
t.Setenv("CARLOS_HOST_CLAIM", "1")
source, store, initial := workFixture(t)
@@ -30,7 +35,10 @@ func servingHandoverFixture(t *testing.T, deviceDelta ...uint64) (ServingHandove
}
return n
}
- old := admit(initial, "first")
+ old := initial
+ if !first {
+ old = admit(initial, "first")
+ }
base := t.TempDir()
disk := &plan14ColdDisk{dir: t.TempDir()}
reg, err := registry.Open(filepath.Join(base, "registry.db"))
@@ -79,6 +87,9 @@ func servingHandoverFixture(t *testing.T, deviceDelta ...uint64) (ServingHandove
{Kind: "route", ArtifactID: "old-route", ManagedRoot: "registry", RelativePath: old.Host, RowIdentity: "old-route", Ownership: owner, Materialization: "materialized"},
{Kind: "path", ArtifactID: "state", ManagedRoot: "data", RelativePath: rel, Ownership: owner, Materialization: "materialized", Device: a.Dev, Inode: a.Ino, Type: a.Type},
}}
+ if omitPath {
+ in.Entries = in.Entries[:1]
+ }
if err = inv.WriteStopInventory(t.Context(), in); err != nil {
t.Fatal(err)
}
@@ -91,11 +102,15 @@ func servingHandoverFixture(t *testing.T, deviceDelta ...uint64) (ServingHandove
t.Fatal(err)
}
prior := hostguard.FenceTarget{FenceID: "stop", Region: "ie", Host: old.Host, Incarnation: holder.Incarnation, Stamp: holder.Stamp, Session: holder.Session, Box: "box", Enrollment: "enrollment", BootID: "boot", ControllerID: "controller", ClaimETag: old.ETag, DirectoryRevision: "1"}
- if _, err = guard.BeginFence(t.Context(), prior); err != nil {
- t.Fatal(err)
- }
- if err = guard.CompleteFence(t.Context(), prior, []byte("verified stop"), time.Now().UTC(), func(context.Context, hostguard.FenceTarget, []byte, time.Time) error { return nil }); err != nil {
- t.Fatal(err)
+ if !first {
+ if _, err = guard.BeginFence(t.Context(), prior); err != nil {
+ t.Fatal(err)
+ }
+ if err = guard.CompleteFence(t.Context(), prior, []byte("verified stop"), time.Now().UTC(), func(context.Context, hostguard.FenceTarget, []byte, time.Time) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ } else {
+ prior.FenceID = ""
}
successor := admit(old, "second")
n := successor.Claim.Holder
@@ -323,3 +338,58 @@ func TestServingHandoverEqualNumbersRequireProof(t *testing.T) {
})
}
}
+
+func TestServingHandoverFreshRouteWithoutState(t *testing.T) {
+ for _, first := range []bool{true, false} {
+ t.Run(fmt.Sprint(first), func(t *testing.T) {
+ h, p, n := servingHandoverInitialFixture(t, first, true)
+ err := h.Run(t.Context(), p, n)
+ if !first {
+ if !errors.Is(err, ErrTypedInventory) {
+ t.Fatal("running predecessor without state", err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal("first route handover", err)
+ }
+ if err := h.Run(t.Context(), p, n); err != nil {
+ t.Fatal("replay", err)
+ }
+ in, err := h.Inventory.ReadInstallation(t.Context(), n.Host, n.Incarnation, n.Stamp)
+ if err != nil || len(in.Entries) != 1 || in.Entries[0].Kind != "route" {
+ t.Fatal("unexpected state authority", in, err)
+ }
+ })
+ }
+}
+
+func TestServingHandoverBarrierIsNotPendingStop(t *testing.T) {
+ h, prior, next := servingHandoverFixture(t)
+ crashed := errors.New("crash after durable barrier")
+ h.checkpoint = func(point string) error {
+ if point == "barrier" {
+ return crashed
+ }
+ return nil
+ }
+ if err := h.Run(t.Context(), prior, next); !errors.Is(err, crashed) {
+ t.Fatal(err)
+ }
+ all, err := h.Guard.PendingFences(t.Context())
+ if err != nil || len(all) != 1 {
+ t.Fatal(all, err)
+ }
+ stops, err := h.Guard.PendingStopFences(t.Context())
+ if err != nil || len(stops) != 0 {
+ t.Fatal("handover exposed as a stop", stops, err)
+ }
+ h.checkpoint = nil
+ if err := h.Recover(t.Context()); err != nil {
+ t.Fatal("recover handover", err)
+ }
+ all, err = h.Guard.PendingFences(t.Context())
+ if err != nil || len(all) != 0 {
+ t.Fatal(all, err)
+ }
+}
diff --git a/internal/hostguard/plan14_root_fences_test.go b/internal/hostguard/plan14_root_fences_test.go
index fc55fdf5f..10944a268 100644
--- a/internal/hostguard/plan14_root_fences_test.go
+++ b/internal/hostguard/plan14_root_fences_test.go
@@ -96,6 +96,10 @@ func TestPlan14RootFences(t *testing.T) {
if err != nil || len(pending) != 1 || pending[0] != target {
t.Fatalf("lost recovery: %+v %v", pending, err)
}
+ stops, err := f.g.PendingStopFences(t.Context())
+ if err != nil || len(stops) != 1 || stops[0] != target {
+ t.Fatalf("ordinary stop missing from recovery: %+v %v", stops, err)
+ }
after, err := store.Read(target.Host + ".json")
if err != nil || string(before) != string(after) {
t.Fatal("root evidence changed", err)
diff --git a/internal/hostguard/recoveryfence.go b/internal/hostguard/recoveryfence.go
index 9bab40b4c..2583179e8 100644
--- a/internal/hostguard/recoveryfence.go
+++ b/internal/hostguard/recoveryfence.go
@@ -8,6 +8,14 @@ import (
)
func (g Guard) PendingFences(ctx context.Context) ([]FenceTarget, error) {
+ return g.pendingFences(ctx, true)
+}
+
+func (g Guard) PendingStopFences(ctx context.Context) ([]FenceTarget, error) {
+ return g.pendingFences(ctx, false)
+}
+
+func (g Guard) pendingFences(ctx context.Context, handovers bool) ([]FenceTarget, error) {
if g.FenceView != nil {
return nil, ErrUnsupported
}
@@ -26,7 +34,7 @@ func (g Guard) PendingFences(ctx context.Context) ([]FenceTarget, error) {
if err != nil {
return err
}
- if r.ActiveFence == nil {
+ if r.ActiveFence == nil || (!handovers && r.root && r.barrier != nil && *r.barrier == *r.ActiveFence) {
return nil
}
for _, receipt := range r.FenceJournal {
diff --git a/internal/provision/testdata/plan14-root-reads-p3.tsv b/internal/provision/testdata/plan14-root-reads-p3.tsv
index 4750fc7d5..2908e8c96 100644
--- a/internal/provision/testdata/plan14-root-reads-p3.tsv
+++ b/internal/provision/testdata/plan14-root-reads-p3.tsv
@@ -118,7 +118,7 @@ internal/safefile/file_linux.go writeAtSteps authority A,F,G rev3=PROPOSED-p3;id
internal/host/rootmigration_collect_linux.go migrationStopKernel.RecoverStopped authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate uid 0;basis=requires dead controller or changed boot plus closed settled root writers; observes real proc descriptors and exact cgroups; compares typed inventory and journal on both sides; records current evidence time downstream file:Read@327 75895f820e1bc7ad3ce1c6dd501655617967ab06631e0e4962e23c54a54ae8d8
internal/host/rootmigration_collect_linux.go recoverMigrationStop authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate uid 0;basis=preserves exact signed or retained receipt; absent signed evidence selects fresh collection only from previously authenticated root journals; begins root fence before observation 4649b2f1a9ee44d7509e57a9fad03e880c19075dbc34141a9a5ce3ad17c6414e
internal/host/rootmigration_collect_linux.go type migrationStopKernel authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate uid 0;basis=root-only offline collector owns checked root journals and managed roots; no legacy candidate can supply inventory authority 09680751d01d45098f9f98b672ec8db92ef0fc55bee36fae45dfd79981456f70
-internal/hostbox/journalnamespace_linux.go prepareJournalDirectory authority C,G,J4 rev3=PROPOSED-p3;identity=offline journal migration and instance-control/adopt startup uid 0;basis=admits only private root-owned authority and held-release alongside existing retire/migration namespaces; every ownership/type/mode check and unknown-directory refusal remains file:Fstat@1026,file:Fstat@436,file:Fstatat@1357,file:Open@177,file:Openat@585,file:Openat@843,file:ReadDir@1242 11d159f47531654c958c62f4f54a22866605dc4bda6ff80c0ef77c02d97255be
+internal/hostbox/journalnamespace_linux.go prepareJournalDirectory authority C,G,J4 rev3=PROPOSED-p3;run=19;identity=root journal startup uid 0;basis=also permits exact private root-owned unit-bindings and unit-materializations directories created by production startup; ownership, mode, type, symlink and unknown-directory refusals remain file:Fstat@1026,file:Fstat@436,file:Fstatat@1357,file:Open@177,file:Openat@585,file:Openat@843,file:ReadDir@1242 71cbb78f96ab5d846f1d4b54f299ad4b70fc5c6067ed9ea5b40d77f746b40628
internal/host/rootfloor_available.go rootFloorAvailable authority F,J4 rev3=PROPOSED-p3;identity=root system apply/recover candidate checks and carlos.prev floor-allows-prev via seeds gate, uid 0;basis=checks signed current, retained prev and fixed staged candidate; a floor constrains replacement only if a qualifying certified copy is available ebd8e7e71f9b8fd6ae3fdffc069f33edcc8495a9b07f50e599ec909d708a1af2
internal/host/rootfloor_available.go systemApplier.activationFloor authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover, root systemd units or offline root CLI, uid 0;basis=keeps no-census main behavior; logs once and permits repair when no available signed copy supports the bound 63bcfdafa93a5091ee4316949a370998684c4e50903453f44436e6cd42a8ac37
internal/host/rootfloor_healthy.go rootMovesFloors.acceptHealthy authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply after successful root health gate, uid 0;basis=after successful root health gate, retains highest accepted build and publishes accepted floor only when that build supports it 3acf2e05253de53bccc1065ded12cdd58be324147ec3f3e21ad99eb145a121d5
@@ -201,11 +201,8 @@ internal/hostguard/rootmigration.go RootAuthority.CompleteMigrationInventory aut
@coverage internal/activator 0a01bc096c64f1886f810f4856d60f9a2642074a8b2ac0c56cbed77d122e76c8
@coverage internal/console 6f4998c39e6d66cd73ea98500000cbecac251dd21dafd58f51a85c2291e0c533
@coverage internal/edgesfile 7b48368150a427e4620f1c9ded49942b688c9e6c9cd983b7da43d3f2c0810b72
-@coverage internal/host 96bc0192539a4d93cbe0b1013e053b8b7710a358ac25acdd4cb3c5ad61914a9a
@coverage internal/host/units bf6ae516012701e0ca41908167e4efd60dd3c2c24e9af1555dec077e8be44461
-@coverage internal/hostbox 4229685edc3f0850fc590cb14582637c75cc5972d3dfd4e808a2bcad5e692f95
@coverage internal/hostfloor 696e5ea4fa37b924b68e8e262235b99298ee19342d875213ba8209e5287dd3c6
-@coverage internal/hostguard d115a22e2a8410adef697af3e73f420b663337dc827fcca82e181e15fdeb8834
@coverage internal/release 1c219907db7ac4ab5d7f5e416c37cd623045e5b5ef759f121ab327effa4017c3
@coverage internal/rootparticipation 7798d05a815af052b9b40d4ff381834a58b2740082e8f673c173e34cb3ce4f22
@coverage internal/rootstate 292c259ffe1e7144029af096cc39691d6239b2dbc5f01583d76d2e3f4653b2cb
@@ -345,7 +342,6 @@ internal/host/rootgeneration_handover.go generationTransfers authority E2 rev3=P
internal/host/rootgeneration_handover.go productionGenerationHandovers authority E2 rev3=PROPOSED-p3;run=16;identity=root claim/adoption controller uid 0;basis=verifies retained completed serving-handover journals against both root payloads, retired predecessor, exact stop receipt, ownership and persistent path identity; follows transfers through terminal retirement without removing launch reservations; TestPlan14ServingHandoverPins and TestPlan14ServingHandoverPinsRootRunner file:Open@172,file:Open@422 a5f5ce3ccf3f0a068b049e29bc804ced7c942d25080038942041f399e86be743
internal/hostbox/generationhandover.go DecodeGenerationHandover authority E2 rev3=PROPOSED-p3;run=16;identity=root claim/adoption controller uid 0;basis=verifies retained completed serving-handover journals against both root payloads, retired predecessor, exact stop receipt, ownership and persistent path identity; follows transfers through terminal retirement without removing launch reservations; TestPlan14ServingHandoverPins and TestPlan14ServingHandoverPinsRootRunner registry:ClaimOwnership@1383,registry:ClaimOwnership@1588,registry:ClaimOwnership@1621,registry:ClaimRowID@1650,registry:Name@1704,registry:Route@1383,registry:Route@1621,registry:Timers@1255,registry:Timers@1300,registry:Timers@1571,registry:Timers@1588,registry:Timers@1650,registry:Timers@1704,whole:ClaimOwnership@1395,whole:ClaimOwnership@1604,whole:ClaimOwnership@1633,whole:ColdRows@1247,whole:ColdRows@1302,whole:ColdRows@1752,whole:ColdRows@1761,whole:Route@1389,whole:Route@1627,whole:Timer@1261,whole:Timer@1309,whole:Timer@1577,whole:Timer@1594,whole:Timer@1656,whole:Timer@1710 3992c3f45d4688db1d1ad88106f535ed3d510046fbd93aa80eccf6b82fd1063c
internal/host/claimparticipation.go productionClaimLocal.participateHost authority D rev3=PROPOSED-p3;run=18;identity=agent uid carlos;basis=opens and verifies root participation, then publishes the exact active lease acknowledgement for ordinary create/delete/purge; legacy participation remains unchanged file:Read@121 ef3af30f918a0c15a7e97caa00013851fd7b749e1faf3459db977dc0db78f0dd
-internal/host/claimproduction.go claimServingLaunchSource authority E2 rev3=PROPOSED-p3;run=18;identity=agent uid carlos;basis=uses authorized claimed-token initialization including a fresh nested instance directory before snapshot; named-unit path and final root launch validation are unchanged registry:Host@697,whole:Route@1139,whole:Route@328,whole:Route@457,whole:Route@614,whole:Route@821 a40211c3f78ad318d0f92f93da2a3acb9e23b3143012f64a5b0d9643cce26a65
internal/host/claimtoken_directory.go ensureClaimLaunchToken authority E2 rev3=PROPOSED-p3;run=18;identity=agent uid carlos;basis=creates only the classified nested instance directory inside the existing claim authorization callback, using no-symlink descriptor operations and parent sync before token initialization; main legacy initializer unchanged registry:Host@519,whole:Route@231,whole:Route@289,whole:Route@426 2feb82aacf5b5f2557e3c00e5d2f17efd72b9863faee71cb353de4e54b48fabc
internal/host/rootexecution_client.go productionClaimLocal.acknowledgeOperation authority C rev3=PROPOSED-p3;run=18;identity=agent uid carlos;basis=publishes only the required participant own active root-client tuple under claim CAS; checks host incarnation operation box enrollment and leaves existing acknowledgement immutable 12c251703e5b06937af6c993068b45dd41dd8304a2b1d543eaa213dc56cfb4d9
internal/hostbox/census_vendor_linux.go censusVendorUnitLink authority G,C rev3=PROPOSED-p3;run=18;identity=root migration/control uid 0; agent census uid carlos;basis=classifies only root-owned trusted systemd links to checked regular vendor units; rejects CARLOS names, untrusted ancestry, foreign targets and nested link paths; non-Linux stub only refuses file:EvalSymlinks@867 c4d781b5c4ed60bd16e7d20db119ba6029c039fe60bbacba0219c407deb3ee0d
@@ -358,3 +354,17 @@ internal/rootparticipation/request.go ExecutionRequest.Valid authority C rev3=PR
internal/rootstate/link_linux.go directory.readLink authority A,E1 rev3=PROPOSED-p3;run=18;identity=root consumers uid 0; agent census uid carlos;basis=trusted symlink reads allow the systemd template character at-sign while retaining descriptor pinning, owner, single-link and path-component checks; authority record names unchanged file:Fstat@386,file:Openat@213,file:Readlinkat@584 93b98a8fb45fd101920c3c3a01f90600711d54e0910f523fdc2c6a8774feb4fa
internal/rootstate/state_linux.go descendSync authority A rev3=PROPOSED-p3;run=18;identity=root consumers uid 0; agent census uid carlos;basis=permits template directory names only for public noncreating traversal; retains root ownership, no writable ancestry, no symlink traversal and all creation restrictions file:Fstat@1101,file:Fstat@1570,file:Openat@597,file:Openat@845 8b6d2741139b1c9d81e5c8104eafcea3e93467399534f868f349d0d11754053b
@coverage internal/hostexec fc41c1b20daf2f36d30932e6851faeac4b632b53c6c532d46ab8e870ccde916c
+internal/host/claimproduction.go claimServingLaunchSource authority E2 rev3=PROPOSED-p3;run=19;identity=agent uid carlos;basis=before credential writes, registers nested state through root inventory intent and verified materialization; read-only snapshots with existing credentials do not reacquire a fenced predecessor registry:Host@858,whole:Route@1302,whole:Route@328,whole:Route@457,whole:Route@614,whole:Route@707,whole:Route@786,whole:Route@984 8ed2d728c47535d9ddf2f8c81ccf3cd4f74e94b594044c0ce8a9448916f652ed
+internal/host/claimtoken_inventory_linux.go claimAdminTokens.prepareState authority C rev3=PROPOSED-p3;run=19;identity=agent uid carlos, independently validated by root journal uid 0;basis=uses current claim, exact installation and deployment-record expectation, root fence view, and the existing two-phase state-directory inventory registration before credential minting file:Read@376,registry:DBPath@753,registry:DBPath@94,registry:Host@150,registry:Host@406,registry:Host@714 e5e33367df92021bf920625050d828016c078a525b6deb5f28163f37d51f762a
+internal/host/claimtoken_inventory_other.go claimAdminTokens.prepareState authority C rev3=PROPOSED-p3;run=19;identity=non-Linux caller;basis=refusal-only stub; cannot register state or mint authority candidate:Route@56 73d3f3886940f05753d25b761d2274b6b953dce650f34795ad03a1d12af77c26
+internal/hostbox/servinghandover.go ServingHandover.validate authority C,J4 rev3=PROPOSED-p3;run=19;identity=root instance-control uid 0;basis=allows route-only initial handover only from verified session zero; later sessions still require exactly one verified state-directory entry; all receipt, inventory and claim checks remain file:Exec@258,registry:ClaimIncarnation@1906,registry:ClaimOwner@1379,registry:ClaimOwner@1980,registry:ClaimOwnership@1874,registry:ClaimRowID@2015,registry:ClaimStamp@1949,registry:Name@1860,registry:Registration@2052,registry:Route@1033,registry:Route@1379,registry:Timers@1806,registry:Timers@2282,whole:ClaimOwnership@1880,whole:ColdRows@1035,whole:ColdRows@1381,whole:ColdRows@1808,whole:ColdRows@2284,whole:Route@1042,whole:Route@1388,whole:Timer@1815,whole:Timer@2291 d8015871bf0cdfb7dfb9715510d1cd649d05aeed8ecded9c8cfb2fae0d6fc946
+internal/host/claimowners.go claimServingOwners.EmptyAuthority authority C,J4 rev3=PROPOSED-p3;run=19;identity=root or agent;basis=first-writer proof rejects all object versions except the exact Litestream lease key at the holder location; leased admission no longer rejects itself 0068e5e3ccc1f15b87a0143393907391f2089729c042f28e389ee972449141ae
+internal/host/claimstop_recovery.go ClaimStopHTTP.RecoverPending authority C,J4 rev3=PROPOSED-p3;run=19;identity=root instance-control;basis=recovers only pending stop fences; persisted handover barriers remain for the handover journal recovery 0376cfcfebdebf8a689d264c47b559a6e5e8deef64768d4e75bebef909be19a1
+internal/hostguard/recoveryfence.go Guard.PendingFences authority C,J2,J4,J1 rev3=PROPOSED-p3;run=19;identity=root;basis=retains complete pending-fence enumeration including handover barriers bc4b4a5d58f470fd0e7b641acc61f2ac4082c021e729534ec4dd00ae423cd03c
+internal/hostguard/recoveryfence.go Guard.PendingStopFences authority C,J4 rev3=PROPOSED-p3;run=19;identity=root;basis=stop-specific enumeration excludes only an exact barrier persisted in the root record f48f8b28b289556e3be77fb76a485f0d4c0a013ec12cb90197071cd2f5ddb87b
+internal/hostguard/recoveryfence.go Guard.pendingFences authority C,J2,J4,J1 rev3=PROPOSED-p3;run=19;identity=root;basis=reads root-owned records under host lock and keeps exact barrier classification separate from ordinary stop recovery; legacy behavior retained 20be9535fd54587a54e3027f5388581ae5a64e5fcfced5a6031ffc9a4fc76a3b
+internal/host/claimroleassembly_linux.go verifiedClaimExecutable authority C,G,J4 rev3=PROPOSED-p3;run=19;identity=agent uid carlos;basis=pins root-owned executable bytes and keeps the verified descriptor while supplying its absolute path to the root launcher file:Fstat@1122,file:Fstat@579,file:OpenFile@376,file:Read@949 7a7e9af520e4157e60ae93d12c3268a629a9b0bd1e83b263cdd27103cf2e227c
+internal/host/claimroleassembly_linux.go claimExecutableName authority C,G,J4 rev3=PROPOSED-p3;run=19;identity=agent uid carlos;basis=duplicates the existing descriptor with CLOEXEC and its validated absolute name; never reopens a potentially replaced path e887880731a16c9e7934aef33a08169b34e7236f6d3b85a195602d35f8b69107
+@coverage internal/host 4f93a7d806207d2d87c068a362c2bcb89259885ea3bd6270cae5cfeb4366592e
+@coverage internal/hostbox 5e2d3da17aef4ad41c5451e482540e83563624b70b8b5f1fbb270ac6e4c5b468
+@coverage internal/hostguard fbfa2f109431ed36c25662b30e56bf8cf16cccb06b10a7e8f41df408b86cae31