carlos / platform Public

WIP claim Plan 14 track E checkpoint 49 (not for merge)

4a: flat named socket, durable unit materialization, root program readback, exact retirement, predecessor handover; all three shapes pass on the runner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 05fb1a4172749ca5f09120ac99938cf46e34fc1a parent 59fcf3f
37 files changed, +1712 −41
  • internal/activator/rolelaunch.go +1 −0
  • internal/activator/rolelaunch_bound_linux_test.go +50 −0
  • internal/activator/rolelaunch_root_linux.go +24 −5
  • internal/host/claimlaunch_authority.go +6 −1
  • internal/host/claimlaunch_root.go +7 −0
  • internal/host/claimlaunch_transport.go +12 −0
  • internal/host/claimowners.go +2 −1
  • internal/host/claimproduction.go +22 −2
  • internal/host/claimretire_transport.go +4 −0
  • internal/host/claimroleassembly.go +3 −0
  • internal/host/claimroleassembly_linux.go +1 −0
  • internal/host/claimunit_binding.go +7 −1
  • internal/host/claimunit_definition.go +27 −1
  • internal/host/claimunit_definition_test.go +23 −0
  • internal/host/claimunit_files_linux.go +130 −0
  • internal/host/claimunit_files_other.go +18 −0
  • internal/host/claimunit_handover.go +72 −0
  • internal/host/claimunit_handover_test.go +87 −0
  • internal/host/claimunit_inputs.go +99 −0
  • internal/host/claimunit_inputs_test.go +107 −0
  • internal/host/claimunit_materialize.go +198 −0
  • internal/host/claimunit_materialize_test.go +127 −0
  • internal/host/claimunit_production.go +70 −4
  • internal/host/claimunit_program.go +1 −1
  • internal/host/claimunit_program_test.go +6 −0
  • internal/host/claimunit_ready_linux.go +126 −0
  • internal/host/claimunit_ready_linux_test.go +40 −0
  • internal/host/claimunit_ready_other.go +13 −0
  • internal/host/claimunit_retire.go +45 −0
  • internal/host/claimunit_retire_test.go +77 −0
  • internal/host/claimwakeassembly.go +8 −1
  • internal/host/plan14_installed_launch_root_linux_test.go +158 −9
  • internal/host/plan14_materialized_unit_root_linux_test.go +39 −0
  • internal/host/plan14_root_generations_test.go +7 −1
  • internal/hostbox/rolelaunch.go +19 −3
  • internal/hostbox/rolelaunch_inputs_test.go +30 −0
  • internal/provision/testdata/plan14-root-reads-e.tsv +46 −11

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	281.678s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.636s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.301s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.705s
ok  	github.com/carlosframework/platform/internal/activator	249.425s
ok  	github.com/carlosframework/platform/internal/agentsock	0.017s
ok  	github.com/carlosframework/platform/internal/alerting	0.005s
ok  	github.com/carlosframework/platform/internal/analytics	0.017s
ok  	github.com/carlosframework/platform/internal/appconfig	2.425s
ok  	github.com/carlosframework/platform/internal/apperr	0.080s
ok  	github.com/carlosframework/platform/internal/applog	0.142s
ok  	github.com/carlosframework/platform/internal/awsinv	0.004s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.978s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.008s
ok  	github.com/carlosframework/platform/internal/caps	0.011s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.694s
ok  	github.com/carlosframework/platform/internal/changelog	0.309s
ok  	github.com/carlosframework/platform/internal/clamd	0.062s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	386.234s
?   	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.423s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.008s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.002s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.373s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.003s
ok  	github.com/carlosframework/platform/internal/edge	26.448s
?   	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.127s
ok  	github.com/carlosframework/platform/internal/gate	1.655s
ok  	github.com/carlosframework/platform/internal/genselect	0.015s
ok  	github.com/carlosframework/platform/internal/geo	0.017s
ok  	github.com/carlosframework/platform/internal/geoip	0.029s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.148s
ok  	github.com/carlosframework/platform/internal/host	143.713s
ok  	github.com/carlosframework/platform/internal/hostactors	0.425s
ok  	github.com/carlosframework/platform/internal/hostbox	2.327s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.492s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.015s
ok  	github.com/carlosframework/platform/internal/hostexec	4.728s
ok  	github.com/carlosframework/platform/internal/hostfence	0.032s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.538s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.015s
ok  	github.com/carlosframework/platform/internal/hostguard	0.992s
ok  	github.com/carlosframework/platform/internal/hostops	1.539s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.015s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.015s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.441s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.149s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.016s
ok  	github.com/carlosframework/platform/internal/infraview	0.015s
ok  	github.com/carlosframework/platform/internal/instancectl	5.220s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.747s
ok  	github.com/carlosframework/platform/internal/instancefs	0.014s
ok  	github.com/carlosframework/platform/internal/instancekey	0.014s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.025s
ok  	github.com/carlosframework/platform/internal/layout	1.512s
ok  	github.com/carlosframework/platform/internal/lease	9.810s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.005s
ok  	github.com/carlosframework/platform/internal/ledger	0.012s
ok  	github.com/carlosframework/platform/internal/mail	0.170s
ok  	github.com/carlosframework/platform/internal/maintenance	0.105s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.501s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.070s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	20.961s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.756s
ok  	github.com/carlosframework/platform/internal/objstore	0.180s
ok  	github.com/carlosframework/platform/internal/ops	0.169s
ok  	github.com/carlosframework/platform/internal/park	0.221s
ok  	github.com/carlosframework/platform/internal/privatefile	0.006s
ok  	github.com/carlosframework/platform/internal/provision	15.944s
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.470s
ok  	github.com/carlosframework/platform/internal/relay	7.229s
ok  	github.com/carlosframework/platform/internal/release	21.464s
ok  	github.com/carlosframework/platform/internal/rollout	3.691s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.004s
ok  	github.com/carlosframework/platform/internal/rootaws	0.006s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.006s
ok  	github.com/carlosframework/platform/internal/rootstate	0.027s
ok  	github.com/carlosframework/platform/internal/runs	0.568s
ok  	github.com/carlosframework/platform/internal/s3compat	0.004s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.460s
ok  	github.com/carlosframework/platform/internal/seeds	0.017s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.222s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.382s
ok  	github.com/carlosframework/platform/internal/status	0.036s
ok  	github.com/carlosframework/platform/internal/steering	0.005s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.212s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.001s
ok  	github.com/carlosframework/platform/internal/vet	1.546s
=== 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: 47251618 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.021s
=== RUN   TestT4StorageProbeMinio
2026/10/15 18:27:32 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEC750E823C67A, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.020s
minio contract: PASS
diff --git a/internal/activator/rolelaunch.go b/internal/activator/rolelaunch.go
index dc6b40c7b..135b33c02 100644
--- a/internal/activator/rolelaunch.go
+++ b/internal/activator/rolelaunch.go
@@ -31,6 +31,7 @@ type RootRoleLauncher interface {
}
type RoleLaunch struct {
+ Unit *hostbox.UnitLaunchInputs
Spec *hostbox.LaunchSpec
// Root carries a reservation to root's isolated launcher. Nil keeps
// every claimed launch refused.
diff --git a/internal/activator/rolelaunch_bound_linux_test.go b/internal/activator/rolelaunch_bound_linux_test.go
new file mode 100644
index 000000000..e8f245be7
--- /dev/null
+++ b/internal/activator/rolelaunch_bound_linux_test.go
@@ -0,0 +1,50 @@
+package activator
+
+import (
+ "os/exec"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/instancectl"
+)
+
+func TestPlan14BoundRoleLaunch(t *testing.T) {
+ for _, attack := range []string{"none", "args", "env", "spec", "unit"} {
+ t.Run(attack, func(t *testing.T) {
+ f := newRootLaunchFixture(t)
+ f.launch.Unit = &hostbox.UnitLaunchInputs{Unit: "woodstar.service", Args: []string{"serve"}, Env: []string{"ROOT_ENV=yes"}}
+ f.launch.Spec = &hostbox.LaunchSpec{Args: []string{"serve"}, Env: []string{"ROOT_ENV=yes"}}
+ f.build = func() *exec.Cmd {
+ cmd := exec.Command(f.exe, "serve")
+ cmd.Env = []string{"ROOT_ENV=yes"}
+ if attack == "args" {
+ cmd.Args[1] = "shell"
+ }
+ if attack == "env" {
+ cmd.Env = []string{"ROOT_ENV=no"}
+ }
+ return cmd
+ }
+ if attack == "spec" {
+ f.launch.Spec.Args = []string{"shell"}
+ }
+ if attack == "unit" {
+ f.launch.Unit.Unit = "../other.service"
+ }
+ _, _, settle, _, err := f.launch.Start(t.Context(), "main", f.attempt, f.build, f.before)
+ if attack != "none" {
+ if err == nil || len(f.root.launches) != 0 {
+ t.Fatal("changed launch reached root", err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer settle(t.Context())
+ if len(f.root.launches) != 1 || f.root.launches[0].Reservation.Group.RelativePath != instancectl.BoundClaimUnitControlGroup("tenant.test", "woodstar.service") {
+ t.Fatal("wrong placement", f.root.launches)
+ }
+ })
+ }
+}
diff --git a/internal/activator/rolelaunch_root_linux.go b/internal/activator/rolelaunch_root_linux.go
index d92c39eff..f87651a9c 100644
--- a/internal/activator/rolelaunch_root_linux.go
+++ b/internal/activator/rolelaunch_root_linux.go
@@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "slices"
"strconv"
"strings"
"time"
@@ -56,14 +57,30 @@ func (l RoleLaunch) startIsolated(ctx context.Context, role string, attempt host
}
}()
reservation := hostbox.LaunchReservation{ID: attempt.ID + ":" + role, Role: role, InvocationID: attempt.InvocationID, Inputs: attempt.LaunchInputs, Target: l.Target, Group: hostbox.LaunchGroup{RelativePath: instancectl.ClaimUnitControlGroup(host)}}
+ if l.Unit != nil {
+ if !instancectl.ValidBoundClaimUnit(l.Unit.Unit) {
+ return fail(hostbox.ErrRoleLaunchShape)
+ }
+ reservation.Group.RelativePath = instancectl.BoundClaimUnitControlGroup(host, l.Unit.Unit)
+ }
err := l.Work.With(ctx, l.Identity, func() error {
cmd = build()
if cmd == nil {
return hostwrite.ErrUnavailable
}
- argv, socket, err := isolatedArgv(cmd, host, l.LaunchDir, l.RunDir)
- if err != nil {
- return err
+ var argv []string
+ var socket string
+ var err error
+ if l.Unit != nil {
+ if l.Spec == nil || len(cmd.Args) == 0 || !slices.Equal(cmd.Args[1:], l.Unit.Args) || !slices.Equal(cmd.Env, l.Unit.Env) || !slices.Equal(l.Spec.Args, l.Unit.Args) || !slices.Equal(l.Spec.Env, l.Unit.Env) {
+ return hostbox.ErrRoleLaunchShape
+ }
+ argv = append([]string{filepath.Join(instancectl.LiveDir, host)}, l.Unit.Args...)
+ } else {
+ argv, socket, err = isolatedArgv(cmd, host, l.LaunchDir, l.RunDir)
+ if err != nil {
+ return err
+ }
}
if l.Initialize != nil {
if err = l.Initialize(ctx); err != nil {
@@ -90,8 +107,10 @@ func (l RoleLaunch) startIsolated(ctx context.Context, role string, attempt host
if err = os.MkdirAll(l.RunDir, 0o750|os.ModeSetgid); err != nil {
return err
}
- if err = os.Remove(socket); err != nil && !errors.Is(err, os.ErrNotExist) {
- return err
+ if socket != "" {
+ if err = os.Remove(socket); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
}
raw, err := json.MarshalIndent(instancectl.Launch{Schema: instancectl.LaunchSchema, Host: host, Argv: argv, Env: append([]string(nil), cmd.Env...)}, "", " ")
if err != nil {
diff --git a/internal/host/claimlaunch_authority.go b/internal/host/claimlaunch_authority.go
index 31bc1eafe..dbd92a35d 100644
--- a/internal/host/claimlaunch_authority.go
+++ b/internal/host/claimlaunch_authority.go
@@ -176,7 +176,12 @@ func (h *ClaimLaunchRoot) waitReady(ctx context.Context, s instancectl.ClaimUnit
if err := h.Units.Same(ctx, s); err != nil {
return ClaimLaunchObserved{}, err
}
- return got, nil
+ if h.boundReady == nil || h.boundReady(ctx, s) == nil {
+ if err := h.Units.Same(ctx, s); err != nil {
+ return ClaimLaunchObserved{}, err
+ }
+ return got, nil
+ }
}
select {
case <-ctx.Done():
diff --git a/internal/host/claimlaunch_root.go b/internal/host/claimlaunch_root.go
index 1848f1760..85489af03 100644
--- a/internal/host/claimlaunch_root.go
+++ b/internal/host/claimlaunch_root.go
@@ -62,6 +62,7 @@ type ClaimLaunchObserved struct {
// roster identity, its local launch journal and its own view of the kernel.
// A request only names which reservation to run.
type ClaimLaunchRoot struct {
+ boundReady func(context.Context, instancectl.ClaimUnitStarted) error
unitProgram func(claimUnitShape) (claimUnitProgram, error)
unitBindings *claimUnitBindings
history rootHistoryAccess
@@ -100,6 +101,12 @@ func (h *ClaimLaunchRoot) Serve(ctx context.Context, peer StopPeer, q hostbox.Ro
}
out := hostbox.RoleLaunchResponse{V: hostbox.RoleLaunchV, Protocol: hostbox.RoleLaunchProtocol}
switch q.Op {
+ case hostbox.RoleLaunchInputs:
+ inputs, err := h.boundInputs(ctx, *q.Route)
+ if err != nil {
+ return hostbox.RoleLaunchResponse{}, err
+ }
+ out.Inputs = inputs
case hostbox.RoleLaunchCapability:
if q.Kind != "" && q.Kind != hostclaim.KindServing {
if (q.Kind != hostclaim.KindLanding && q.Kind != hostclaim.KindRelocating) || h.Attempts[q.Kind] == nil {
diff --git a/internal/host/claimlaunch_transport.go b/internal/host/claimlaunch_transport.go
index 89b070213..2740b7a2b 100644
--- a/internal/host/claimlaunch_transport.go
+++ b/internal/host/claimlaunch_transport.go
@@ -11,6 +11,7 @@ import (
"github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/registry"
)
// ClaimRoleRootClient is the agent's client of POST /claim/launch on the
@@ -124,3 +125,14 @@ func (c *ClaimRoleRootClient) KindCapability(ctx context.Context, kind string) e
}
return nil
}
+
+func (c *ClaimRoleRootClient) UnitInputs(ctx context.Context, route registry.Route) (*hostbox.UnitLaunchInputs, error) {
+ out, err := c.call(ctx, hostbox.RoleLaunchRequest{V: hostbox.RoleLaunchV, Op: hostbox.RoleLaunchInputs, Route: &route})
+ if err != nil {
+ return nil, err
+ }
+ if out.Protocol != hostbox.RoleLaunchProtocol {
+ return nil, hostbox.ErrChanged
+ }
+ return out.Inputs, nil
+}
diff --git a/internal/host/claimowners.go b/internal/host/claimowners.go
index 75bf335ae..e4bb1ecbf 100644
--- a/internal/host/claimowners.go
+++ b/internal/host/claimowners.go
@@ -103,6 +103,7 @@ func claimRelocationC1(getenv func(string) string, movesC1 bool, r ClaimRelocati
// environment, the cgroup limit, the live version, and the config and
// release identities the provider's InputPolicy accepts for that route.
type ClaimServingLaunch struct {
+ Unit *hostbox.UnitLaunchInputs
Identity hostwrite.Identity
Executable string
ExecutableSHA256 string
@@ -424,7 +425,7 @@ func (s *claimServingOwners) Select(ctx context.Context, rt registry.Route, o ho
if len(roles) == 0 {
roles = []string{"main"}
}
- in := ClaimLaunchInputs{Roles: roles, Identity: l.Identity, Executable: l.Executable, ExecutableSHA256: l.ExecutableSHA256, Args: append([]string(nil), l.Args...), Env: append([]string(nil), l.Env...), DBPath: rt.DBPath, Version: l.Version, Staged: l.Staged, Limit: l.Limit,
+ in := ClaimLaunchInputs{Unit: l.Unit, Roles: roles, Identity: l.Identity, Executable: l.Executable, ExecutableSHA256: l.ExecutableSHA256, Args: append([]string(nil), l.Args...), Env: append([]string(nil), l.Env...), DBPath: rt.DBPath, Version: l.Version, Staged: l.Staged, Limit: l.Limit,
Admission: hostclaim.Admission{ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: s.Box, Mode: "cold", NewGeneration: generation, Region: s.Region}}
if claimEmptyBootstrap(h) {
in.Empty = true
diff --git a/internal/host/claimproduction.go b/internal/host/claimproduction.go
index 6d112723c..53f40ddbf 100644
--- a/internal/host/claimproduction.go
+++ b/internal/host/claimproduction.go
@@ -16,6 +16,7 @@ import (
"github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/boxid"
+ "github.com/carlosframework/platform/internal/controlsock"
"github.com/carlosframework/platform/internal/genselect"
"github.com/carlosframework/platform/internal/hostactors"
"github.com/carlosframework/platform/internal/hostbox"
@@ -179,7 +180,7 @@ func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store r
// The token mint is the only write this source makes. It runs under the
// same guard and claim store the assembly's WorkSource uses.
tokens := claimAdminTokens{Guard: hostguard.Guard{DataDir: r.DataDir, Claims: hostguard.StoreReader{Store: store}}, Claims: store, Verify: out.Identity.Verify, Local: out.Identity.Local(local), Policy: hostbox.Policy{Held: hostbox.RegistryHoldPolicy(r.Registry)}.Check, Now: time.Now}
- out.Serving.Launch = claimServingLaunchSource(backend, tokens)
+ out.Serving.Launch = claimServingLaunchSource(backend, tokens, NewClaimRoleRootClient(controlsock.Path).UnitInputs)
out.Serving.Markers = marker
if hostwrite.Enabled(claimEntryEnv(ctx)) && moves.Enabled(claimEntryEnv(ctx)) {
out.moveRead = &claimMoveReadAccess{check: claimBoundIdentity(out.Identity.Verify, actor), actor: actor, route: r.Registry.Get, launch: claimMoveLaunchSource(backend)}
@@ -281,6 +282,16 @@ func claimInputPolicy(route func(string) (*registry.Route, error), store release
if id.Release != "" && id.Config == "" {
return release.CheckRouteReleaseIdentity(ctx, store, pub, *rt, id)
}
+ if rt.Unit != "" || rt.Backing != "" {
+ snapshot, err := claimBoundSnapshot(ctx, backend, *rt, NewClaimRoleRootClient(controlsock.Path).UnitInputs)
+ if err != nil {
+ return err
+ }
+ if id != snapshot.Identity {
+ return hostbox.ErrChanged
+ }
+ return nil
+ }
snapshot, err := backend.SnapshotClaimLaunch(ctx, *rt)
if err != nil {
return err
@@ -511,8 +522,17 @@ func (t claimAdminTokens) With(ctx context.Context, host string, effect func() e
// authorized token initialization, then the read-only snapshot with the
// replica gate (SelectClaimLaunch), then a declined box's launch gate
// (declinedClaimGate).
-func claimServingLaunchSource(backend *activator.ExecBackend, tokens claimAdminTokens) func(context.Context, registry.Route) (ClaimServingLaunch, error) {
+func claimServingLaunchSource(backend *activator.ExecBackend, tokens claimAdminTokens, readers ...claimUnitInputReader) func(context.Context, registry.Route) (ClaimServingLaunch, error) {
return func(ctx context.Context, rt registry.Route) (ClaimServingLaunch, error) {
+ if len(readers) != 0 {
+ unit, err := readers[0](ctx, rt)
+ if err != nil {
+ return ClaimServingLaunch{}, err
+ }
+ if unit != nil {
+ return claimBoundSnapshot(ctx, backend, rt, func(context.Context, registry.Route) (*hostbox.UnitLaunchInputs, error) { return unit, nil })
+ }
+ }
if err := backend.EnsureClaimAdminToken(ctx, rt, func(ctx context.Context, effect func() error) error { return tokens.With(ctx, rt.Host, effect) }); err != nil {
return ClaimServingLaunch{}, err
}
diff --git a/internal/host/claimretire_transport.go b/internal/host/claimretire_transport.go
index d53fad314..ca6ded624 100644
--- a/internal/host/claimretire_transport.go
+++ b/internal/host/claimretire_transport.go
@@ -22,6 +22,7 @@ import (
)
type ClaimRootHTTP struct {
+ retireBoundUnit func(context.Context, hostguard.FenceTarget) error
history rootHistoryAccess
executionRuntime *claimExecutionRuntime
Execution *execlease.RootBroker
@@ -163,6 +164,9 @@ func (h ClaimRootHTTP) retire(ctx context.Context, target hostguard.FenceTarget)
a.Stop = func(context.Context, registry.Route) (hostguard.FenceTarget, error) { return target, nil }
a.Retirement.Filesystem = hostbox.TypedParker{Roots: h.Roots, ParkedRoots: h.Roots, Incarnation: target.Incarnation, Host: target.Host, Retirement: target.FenceID}
a.Retirement.Authority = hostbox.InstallationCleanup{Guard: a.Reconciler.Guard, Verify: verifyRetirementReceipt}
+ if h.retireBoundUnit != nil {
+ a.Retirement.Authority = claimBoundUnitCleanup{inner: a.Retirement.Authority, retire: h.retireBoundUnit}
+ }
a.Retirement.Ack = retirementAcknowledgement(h.Stop.Coordinator.Evidence)
return a.Retire(ctx, row)
}
diff --git a/internal/host/claimroleassembly.go b/internal/host/claimroleassembly.go
index 3a108aa60..02cc601f1 100644
--- a/internal/host/claimroleassembly.go
+++ b/internal/host/claimroleassembly.go
@@ -94,6 +94,9 @@ func (a *ClaimRoleAssembly) PreflightLaunch(host string, in ClaimLaunchInputs) e
if err != nil {
return err
}
+ if in.Unit != nil {
+ return claimBoundInputMatch(host, in)
+ }
// The same validator startIsolated runs on the built command.
_, socket, db, err := activator.IsolatedArgv(host, paths.LaunchDir, paths.RunDir, in.Args, in.Env)
if err != nil {
diff --git a/internal/host/claimroleassembly_linux.go b/internal/host/claimroleassembly_linux.go
index ad98346c0..fdf59e3e2 100644
--- a/internal/host/claimroleassembly_linux.go
+++ b/internal/host/claimroleassembly_linux.go
@@ -157,6 +157,7 @@ func (a *ClaimRoleAssembly) Launch(ctx context.Context, o hostclaim.Observed, wo
return nil, hostwrite.ErrUnavailable
}
launch := *pre
+ launch.Unit = in.Unit
launch.Spec = &hostbox.LaunchSpec{Identity: in.Identity, Executable: in.Executable, Digest: in.ExecutableSHA256, Args: append([]string(nil), in.Args...), Env: append([]string(nil), in.Env...)}
launch.Target, launch.Work, launch.Identity = target, work, in.Identity
var mu sync.Mutex
diff --git a/internal/host/claimunit_binding.go b/internal/host/claimunit_binding.go
index 8221dc55b..4910a370d 100644
--- a/internal/host/claimunit_binding.go
+++ b/internal/host/claimunit_binding.go
@@ -32,6 +32,7 @@ type claimUnitBinding struct {
}
type claimUnitBindings struct {
+ handover func(context.Context, claimUnitBinding, claimUnitBinding) error
state rootstate.Store
installation func(context.Context, string) (hostguard.RootInstallation, error)
canonical func(context.Context, string) (claimUnitShape, error)
@@ -173,7 +174,12 @@ func (s *claimUnitBindings) establish(ctx context.Context, host string) (claimUn
old, err := s.read(host)
if err == nil {
if old != next {
- return claimUnitBinding{}, os.ErrPermission
+ if !old.Retired || old.Shape != next.Shape || old.Owner == next.Owner || s.handover == nil {
+ return claimUnitBinding{}, os.ErrPermission
+ }
+ if err := s.handover(ctx, old, next); err != nil {
+ return claimUnitBinding{}, err
+ }
}
} else if !errors.Is(err, os.ErrNotExist) {
return claimUnitBinding{}, err
diff --git a/internal/host/claimunit_definition.go b/internal/host/claimunit_definition.go
index 9c72d987b..6aa089861 100644
--- a/internal/host/claimunit_definition.go
+++ b/internal/host/claimunit_definition.go
@@ -24,6 +24,13 @@ func claimBoundUnitDefinition(s claimUnitShape) ([]byte, error) {
// With no live root gate, even a direct systemctl restart must execute no
// tenant code. The temporary reservation drop-in replaces this command.
body = strings.Replace(body, "ExecStart="+instancectl.SystemBinary+" instance-exec run "+provision.InstanceID(s.Host), "ExecStart=/usr/bin/false", 1)
+ if s.Socket != "" && s.Socket != provision.NestedSocket(s.Host) {
+ if !claimFlatSocket(s) {
+ return nil, os.ErrPermission
+ }
+ body = strings.Replace(body, "TemporaryFileSystem=/run/carlos:ro\n", "", 1)
+ body += "\n[Service]\nBindPaths=" + provision.InstanceRunDir(s.Host) + ":/run/carlos\nReadWritePaths=/run/carlos\n"
+ }
if s.Addr != "" {
body += "\n[Service]\nIPAddressAllow=localhost\n"
}
@@ -35,7 +42,7 @@ func verifyClaimBoundUnitDefinition(s claimUnitShape) error {
if err != nil {
return err
}
- f, err := rootstate.OpenAuthorityFile(filepath.Join("/run/systemd/system", s.Unit))
+ f, err := rootstate.OpenTrustedFile(filepath.Join("/run/systemd/system", s.Unit))
if err != nil {
return err
}
@@ -49,3 +56,22 @@ func verifyClaimBoundUnitDefinition(s claimUnitShape) error {
}
return nil
}
+
+func claimFlatSocket(s claimUnitShape) bool {
+ if filepath.Dir(s.Socket) != provision.SocketDir || !strings.HasSuffix(s.Socket, ".sock") {
+ return false
+ }
+ for _, r := range filepath.Base(s.Socket) {
+ if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') {
+ return false
+ }
+ }
+ return true
+}
+
+func claimSocketTarget(s claimUnitShape) string {
+ if claimFlatSocket(s) {
+ return filepath.Join(provision.InstanceRunDir(s.Host), filepath.Base(s.Socket))
+ }
+ return s.Socket
+}
diff --git a/internal/host/claimunit_definition_test.go b/internal/host/claimunit_definition_test.go
index bffe259b6..46570b851 100644
--- a/internal/host/claimunit_definition_test.go
+++ b/internal/host/claimunit_definition_test.go
@@ -33,6 +33,7 @@ func TestPlan14BoundUnitDefinition(t *testing.T) {
}
}
shape.Host = "woodstar-media.example"
+ shape.Socket = provision.NestedSocket(shape.Host)
raw, err := claimBoundUnitDefinition(shape)
if err != nil {
t.Fatal(err)
@@ -41,3 +42,25 @@ func TestPlan14BoundUnitDefinition(t *testing.T) {
t.Fatal("named unit used unescaped slice")
}
}
+
+func TestPlan14FlatBoundSocketDefinition(t *testing.T) {
+ _, _, shape := plan14UnitBindingFixture(t, "woodstar-media.service", "", "/run/carlos/woodstar-media.sock")
+ shape.Program = strings.Repeat("a", 64)
+ raw, err := claimBoundUnitDefinition(shape)
+ if err != nil {
+ t.Fatal(err)
+ }
+ own := provision.InstanceRunDir(shape.Host)
+ if !strings.Contains(string(raw), "BindPaths="+own+":/run/carlos\n") || !strings.Contains(string(raw), "ReadWritePaths=/run/carlos\n") {
+ t.Fatal("flat path has no private run view")
+ }
+ if strings.Contains(string(raw), "BindPaths=/run/carlos:/run/carlos") || strings.Contains(string(raw), "TemporaryFileSystem=/run/carlos:ro") {
+ t.Fatal("flat socket exposes siblings")
+ }
+ for _, socket := range []string{"/run/carlos/../other.sock", "/run/carlos/other/path.sock", "/run/carlos/space name.sock", "/run/carlos/percent%name.sock", "/elsewhere/app.sock"} {
+ shape.Socket = socket
+ if _, err := claimBoundUnitDefinition(shape); err == nil {
+ t.Fatal("unsafe socket", socket)
+ }
+ }
+}
diff --git a/internal/host/claimunit_files_linux.go b/internal/host/claimunit_files_linux.go
new file mode 100644
index 000000000..32b1bfd31
--- /dev/null
+++ b/internal/host/claimunit_files_linux.go
@@ -0,0 +1,130 @@
+package host
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/rootstate"
+ "github.com/carlosframework/platform/internal/safefile"
+ "golang.org/x/sys/unix"
+)
+
+type claimUnitFiles struct{ dir string }
+
+func (f claimUnitFiles) read(unit string) ([]byte, rootstate.FileIdentity, error) {
+ if !instancectl.ValidBoundClaimUnit(unit) {
+ return nil, rootstate.FileIdentity{}, os.ErrPermission
+ }
+ file, err := rootstate.OpenTrustedFile(filepath.Join(f.dir, unit))
+ if err != nil {
+ return nil, rootstate.FileIdentity{}, err
+ }
+ defer file.Close()
+ raw, err := io.ReadAll(io.LimitReader(file, rootstate.MaxRecord+1))
+ if err != nil {
+ return nil, rootstate.FileIdentity{}, err
+ }
+ if len(raw) > rootstate.MaxRecord {
+ return nil, rootstate.FileIdentity{}, os.ErrPermission
+ }
+ id, err := rootstate.Identity(file)
+ return raw, id, err
+}
+func (f claimUnitFiles) create(unit string, body []byte) error {
+ return f.write(unit, rootstate.FileIdentity{}, body)
+}
+func (f claimUnitFiles) replace(unit string, id rootstate.FileIdentity, body []byte) error {
+ if id == (rootstate.FileIdentity{}) {
+ return os.ErrPermission
+ }
+ return f.write(unit, id, body)
+}
+func (f claimUnitFiles) write(unit string, id rootstate.FileIdentity, body []byte) error {
+ if !instancectl.ValidBoundClaimUnit(unit) {
+ return os.ErrPermission
+ }
+ checked, err := rootstate.Open(f.dir, false, true)
+ if err != nil {
+ return err
+ }
+ defer checked.Close()
+ parent, err := safefile.OpenDir(f.dir)
+ if err != nil {
+ return err
+ }
+ defer parent.Close()
+ tmp, err := os.CreateTemp(f.dir, ".claim-unit-")
+ if err != nil {
+ return err
+ }
+ name := filepath.Base(tmp.Name())
+ defer unix.Unlinkat(int(parent.Fd()), name, 0)
+ _, err = tmp.Write(body)
+ if err == nil {
+ err = tmp.Chmod(0644)
+ }
+ if err == nil {
+ err = tmp.Sync()
+ }
+ if err = errors.Join(err, tmp.Close()); err != nil {
+ return err
+ }
+ flags := uint(unix.RENAME_NOREPLACE)
+ if id != (rootstate.FileIdentity{}) {
+ _, current, err := f.read(unit)
+ if err != nil {
+ return err
+ }
+ if current != id {
+ return os.ErrPermission
+ }
+ flags = 0
+ }
+ if err = unix.Renameat2(int(parent.Fd()), name, int(parent.Fd()), unit, flags); err != nil {
+ return err
+ }
+ if err = parent.Sync(); err != nil {
+ return err
+ }
+ got, _, err := f.read(unit)
+ if err != nil {
+ return err
+ }
+ if !bytes.Equal(got, body) {
+ return os.ErrPermission
+ }
+ return nil
+}
+
+// The flat name is only a routing projection. Root launch and containment
+// retain the binding and the private run directory independently of it.
+func projectClaimBoundSocket(s claimUnitShape) error {
+ if !claimFlatSocket(s) {
+ return nil
+ }
+ parent, err := safefile.OpenDir(filepath.Dir(s.Socket))
+ if err != nil {
+ return err
+ }
+ defer parent.Close()
+ name := filepath.Base(s.Socket)
+ target := claimSocketTarget(s)
+ if err := unix.Symlinkat(target, int(parent.Fd()), name); err != nil {
+ if !errors.Is(err, unix.EEXIST) {
+ return err
+ }
+ var buf [4096]byte
+ n, err := unix.Readlinkat(int(parent.Fd()), name, buf[:])
+ if err != nil {
+ return err
+ }
+ if string(buf[:n]) != target {
+ return os.ErrPermission
+ }
+ }
+ return parent.Sync()
+}
diff --git a/internal/host/claimunit_files_other.go b/internal/host/claimunit_files_other.go
new file mode 100644
index 000000000..950f74ccb
--- /dev/null
+++ b/internal/host/claimunit_files_other.go
@@ -0,0 +1,18 @@
+//go:build !linux
+
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/rootstate"
+ "os"
+)
+
+type claimUnitFiles struct{ dir string }
+
+func (claimUnitFiles) read(string) ([]byte, rootstate.FileIdentity, error) {
+ return nil, rootstate.FileIdentity{}, os.ErrPermission
+}
+func (claimUnitFiles) create(string, []byte) error { return os.ErrPermission }
+func (claimUnitFiles) replace(string, rootstate.FileIdentity, []byte) error { return os.ErrPermission }
+
+func projectClaimBoundSocket(claimUnitShape) error { return os.ErrPermission }
diff --git a/internal/host/claimunit_handover.go b/internal/host/claimunit_handover.go
new file mode 100644
index 000000000..239dee805
--- /dev/null
+++ b/internal/host/claimunit_handover.go
@@ -0,0 +1,72 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "os"
+
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+// The caller holds the binding lock and has resolved the successor from the
+// current root installation. A route edit never reaches this transition.
+func (m claimUnitMaterializer) handover(ctx context.Context, old, next claimUnitBinding) error {
+ if m.state == nil || m.read == nil || m.replace == nil || m.idle == nil || !old.valid() || !next.valid() || !old.Retired || next.Retiring || next.Retired || old.Shape != next.Shape || old.Owner == next.Owner {
+ return os.ErrPermission
+ }
+ unlock, err := m.state.Lock(ctx, "materialize")
+ if err != nil {
+ return err
+ }
+ defer unlock()
+ r, err := m.load(old.Owner.Host)
+ if err != nil {
+ return err
+ }
+ want, err := claimOwnedUnitDefinition(next)
+ if err != nil {
+ return err
+ }
+ previous := old
+ previous.Retiring, previous.Retired = false, false
+ if r.Binding == previous {
+ if !r.Retired || r.Identity == (rootstate.FileIdentity{}) {
+ return os.ErrPermission
+ }
+ raw, id, err := m.read(old.Shape.Unit)
+ if err != nil {
+ return err
+ }
+ if id != r.Identity || !bytes.Equal(raw, claimRetiredUnitDefinition(old)) {
+ return os.ErrPermission
+ }
+ r = claimUnitMaterialization{Binding: next, Digest: claimUnitDigest(want), Prior: old, PriorIdentity: r.Identity}
+ }
+ if r.Binding != next || r.Prior != old || r.Digest != claimUnitDigest(want) || r.Retiring || r.Retired {
+ return os.ErrPermission
+ }
+ if err = m.save(r); err != nil {
+ return err
+ }
+ raw, id, err := m.read(next.Shape.Unit)
+ if err != nil {
+ return err
+ }
+ if r.Identity == (rootstate.FileIdentity{}) && id == r.PriorIdentity && bytes.Equal(raw, claimRetiredUnitDefinition(old)) {
+ if err = m.idle(next.Shape.Host); err != nil {
+ return err
+ }
+ if err = m.replace(next.Shape.Unit, r.PriorIdentity, want); err != nil {
+ return err
+ }
+ raw, id, err = m.read(next.Shape.Unit)
+ if err != nil {
+ return err
+ }
+ }
+ if !bytes.Equal(raw, want) || id == (rootstate.FileIdentity{}) || (r.Identity != (rootstate.FileIdentity{}) && r.Identity != id) {
+ return os.ErrPermission
+ }
+ r.Identity = id
+ return m.save(r)
+}
diff --git a/internal/host/claimunit_handover_test.go b/internal/host/claimunit_handover_test.go
new file mode 100644
index 000000000..f0c812de4
--- /dev/null
+++ b/internal/host/claimunit_handover_test.go
@@ -0,0 +1,87 @@
+package host
+
+import (
+ "context"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func TestPlan14UnitPredecessorHandover(t *testing.T) {
+ for _, attack := range []string{"none", "not retired", "same owner", "shape", "replacement", "lost materialization"} {
+ t.Run(attack, func(t *testing.T) {
+ b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "")
+ shape.Program = strings.Repeat("a", 64)
+ b.canonical = func(context.Context, string) (claimUnitShape, error) { return shape, nil }
+ b.installation = func(context.Context, string) (hostguard.RootInstallation, error) { return in, nil }
+ files := &plan14UnitFiles{}
+ state := &plan14UnitBindingFault{Store: &rollbackMemory{files: map[string][]byte{}}}
+ m := claimUnitMaterializer{idle: func(string) error { return nil }, state: state, read: files.read, create: files.create, replace: files.replace}
+ b.handover = m.handover
+ old, err := b.establish(t.Context(), in.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = m.install(t.Context(), old); err != nil {
+ t.Fatal(err)
+ }
+ if attack != "not retired" {
+ if err = b.retire(t.Context(), old, func(claimUnitBinding) error { return m.retire(t.Context(), old, func() error { return nil }) }); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if attack != "same owner" {
+ in.Stamp++
+ }
+ if attack == "shape" {
+ shape.Addr = "127.0.0.1:9999"
+ }
+ if attack == "replacement" {
+ files.id.Inode++
+ }
+ original := m.replace
+ if attack == "lost materialization" {
+ m.replace = func(unit string, id rootstate.FileIdentity, body []byte) error {
+ if err := original(unit, id, body); err != nil {
+ return err
+ }
+ return os.ErrInvalid
+ }
+ b.handover = m.handover
+ }
+ next, err := b.establish(t.Context(), in.Host)
+ if attack != "none" && attack != "lost materialization" {
+ if err == nil {
+ t.Fatal("unsafe handover accepted", next)
+ }
+ return
+ }
+ if attack == "lost materialization" {
+ if err == nil {
+ t.Fatal("lost replacement reported success")
+ }
+ m.replace = original
+ b.handover = m.handover
+ next, err = b.establish(t.Context(), in.Host)
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if next.Owner.Stamp != in.Stamp || next.Retired || next.Retiring {
+ t.Fatal(next)
+ }
+ if err = m.verify(next); err != nil {
+ t.Fatal(err)
+ }
+ if err = b.retire(t.Context(), old, func(claimUnitBinding) error { t.Fatal("predecessor reached successor"); return nil }); err == nil {
+ t.Fatal("stale retirement accepted")
+ }
+ if _, err = b.establish(t.Context(), in.Host); err != nil {
+ t.Fatal("replay", err)
+ }
+ })
+ }
+}
diff --git a/internal/host/claimunit_inputs.go b/internal/host/claimunit_inputs.go
new file mode 100644
index 000000000..5c1c5d8ed
--- /dev/null
+++ b/internal/host/claimunit_inputs.go
@@ -0,0 +1,99 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "slices"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func (h *ClaimLaunchRoot) boundInputs(ctx context.Context, rt registry.Route) (*hostbox.UnitLaunchInputs, error) {
+ if h.unitBindings == nil || h.unitProgram == nil {
+ return nil, os.ErrPermission
+ }
+ var result *hostbox.UnitLaunchInputs
+ err := func() error {
+ b, err := h.unitBindings.read(rt.Host)
+ if errors.Is(err, os.ErrNotExist) {
+ if _, e := h.unitBindings.canonical(ctx, rt.Host); !errors.Is(e, os.ErrNotExist) {
+ return os.ErrPermission
+ }
+ if rt.Unit != "" || rt.Backing != "" || rt.Tunnel != "" {
+ return hostbox.ErrUnsupported
+ }
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ b, err = h.unitBindings.check(ctx, rt.Host, &rt)
+ if err != nil {
+ return err
+ }
+ p, err := h.unitProgram(b.Shape)
+ if err != nil {
+ return err
+ }
+ result = &hostbox.UnitLaunchInputs{Unit: b.Shape.Unit, Socket: b.Shape.Socket, Addr: b.Shape.Addr, DBPath: b.Shape.DBPath, Program: b.Shape.Program, Args: append([]string(nil), p.Args...), Env: append([]string(nil), p.Env...)}
+ return nil
+ }()
+ return result, err
+}
+
+type claimUnitInputReader func(context.Context, registry.Route) (*hostbox.UnitLaunchInputs, error)
+
+func claimBoundSnapshot(ctx context.Context, backend *activator.ExecBackend, rt registry.Route, read claimUnitInputReader) (ClaimServingLaunch, error) {
+ var out ClaimServingLaunch
+ unit, err := read(ctx, rt)
+ if err != nil {
+ return out, err
+ }
+ if unit == nil {
+ return out, hostbox.ErrUnsupported
+ }
+ if err = validateClaimUnitInputs(rt.Host, unit); err != nil {
+ return out, err
+ }
+ id, err := release.ResolveLive(backend.OptDir, rt.Host)
+ if err != nil {
+ return out, err
+ }
+ if id.Version == "" || !claimHexDigest(id.Digest) {
+ return out, hostbox.ErrChanged
+ }
+ out = ClaimServingLaunch{Unit: unit, Identity: hostwrite.Identity{Config: unit.Program, Release: id.Digest}, Executable: release.LiveLink(backend.OptDir, rt.Host), ExecutableSHA256: id.Digest, Args: unit.Args, Env: unit.Env, Version: id.Version, Staged: id, Limit: cgroup.TierDefault, Roles: []string{"main"}}
+ return out, nil
+}
+
+func validateClaimUnitInputs(host string, in *hostbox.UnitLaunchInputs) error {
+ if in == nil || !claimHexDigest(in.Program) || !provision.ValidHostShape(host) || filepath.Dir(in.DBPath) != provision.InstanceDir("/data", host) {
+ return hostbox.ErrRoleLaunchShape
+ }
+ if !instancectl.ValidBoundClaimUnit(in.Unit) || (in.Socket == "") == (in.Addr == "") || filepath.Clean(in.DBPath) != in.DBPath {
+ return hostbox.ErrRoleLaunchShape
+ }
+ if in.Socket != "" && in.Socket != provision.NestedSocket(host) && !claimFlatSocket(claimUnitShape{Socket: in.Socket}) {
+ return hostbox.ErrRoleLaunchShape
+ }
+ return nil
+}
+
+func claimBoundInputMatch(host string, in ClaimLaunchInputs) error {
+ if err := validateClaimUnitInputs(host, in.Unit); err != nil {
+ return err
+ }
+ if in.DBPath != in.Unit.DBPath || in.Identity.Config != in.Unit.Program || !slices.Equal(in.Args, in.Unit.Args) || !slices.Equal(in.Env, in.Unit.Env) {
+ return hostbox.ErrRoleLaunchShape
+ }
+ return nil
+}
diff --git a/internal/host/claimunit_inputs_test.go b/internal/host/claimunit_inputs_test.go
new file mode 100644
index 000000000..714a5b5a6
--- /dev/null
+++ b/internal/host/claimunit_inputs_test.go
@@ -0,0 +1,107 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/provision"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func TestPlan14BoundUnitInputs(t *testing.T) {
+ for _, unit := range []string{"console.service", "woodstar-atbridge.service", "carlos-app@shop.example.service"} {
+ t.Run(unit, func(t *testing.T) {
+ f, _, _ := generationLaunchFixture(t)
+ b, _, shape := plan14UnitBindingFixture(t, unit, "", "")
+ if unit == "woodstar-atbridge.service" {
+ shape.Addr = ""
+ shape.Socket = "/run/carlos/woodstar-atbridge.sock"
+ }
+ if unit == "carlos-app@shop.example.service" {
+ shape.Backing = "unit"
+ }
+ in, err := f.root.Guard.Root.Installation(t.Context(), f.target.Host, hostguard.Captured{Incarnation: f.target.Incarnation, Stamp: f.target.Stamp})
+ if err != nil {
+ t.Fatal(err)
+ }
+ program := claimUnitProgram{Schema: 1, Host: in.Host, Args: []string{"serve"}, Env: []string{"SECRET=root-config"}}
+ raw, _ := json.Marshal(program)
+ shape.Program = claimUnitDigest(raw)
+ b.installation = func(context.Context, string) (hostguard.RootInstallation, error) { return in, nil }
+ b.canonical = func(context.Context, string) (claimUnitShape, error) { return shape, nil }
+ if _, err = b.establish(t.Context(), in.Host); err != nil {
+ t.Fatal(err)
+ }
+ f.root.unitBindings = b
+ f.root.unitProgram = func(s claimUnitShape) (claimUnitProgram, error) { return decodeClaimUnitProgram(raw, s) }
+ route := shape.route()
+ locked, cancel := context.WithTimeout(t.Context(), time.Second)
+ defer cancel()
+ if err := f.root.Guard.WithLocal(locked, in.Host, func() error { _, err := f.root.boundInputs(locked, route); return err }); err != nil {
+ t.Fatal("input view reacquired host lock", err)
+ }
+ q := hostbox.RoleLaunchRequest{V: hostbox.RoleLaunchV, Op: hostbox.RoleLaunchInputs, Route: &route}
+ out, err := f.root.Serve(t.Context(), f.peer, q)
+ if err != nil || out.Inputs == nil || out.Inputs.Unit != unit || out.Inputs.Program != shape.Program || out.Inputs.Env[0] != program.Env[0] {
+ t.Fatal(out, err)
+ }
+ for _, change := range []func(*registry.Route){
+ func(r *registry.Route) { r.Unit = "other.service" }, func(r *registry.Route) { r.Unit = ""; r.Backing = "" },
+ func(r *registry.Route) { r.Backing = "exec" }, func(r *registry.Route) { r.Socket = "/run/carlos/other.sock" },
+ func(r *registry.Route) { r.Addr = "127.0.0.1:9999" }, func(r *registry.Route) { r.DBPath = "/data/other.db" },
+ func(r *registry.Route) { r.Tunnel = "tunnel" }, func(r *registry.Route) { r.Seed = "seed" }, func(r *registry.Route) { r.Replica = true },
+ } {
+ forged := route
+ change(&forged)
+ q.Route = &forged
+ if _, err = f.root.Serve(t.Context(), f.peer, q); err == nil {
+ t.Fatal("changed route accepted", forged)
+ }
+ }
+ q.Route = &route
+ raw = append(raw, ' ')
+ if _, err = f.root.Serve(t.Context(), f.peer, q); err == nil {
+ t.Fatal("changed root program accepted")
+ }
+ })
+ }
+}
+
+func TestPlan14BoundColdSelection(t *testing.T) {
+ f := newAdminTokenFixture(t, "box-1")
+ unit := &hostbox.UnitLaunchInputs{Unit: "woodstar.service", Socket: "/run/carlos/woodstar.sock", DBPath: provision.NestedDBPath("/data", f.rt.Host), Program: strings.Repeat("a", 64), Args: []string{"serve"}, Env: []string{"ROOT_PROGRAM=yes"}}
+ f.rt.Unit = unit.Unit
+ f.rt.Socket = unit.Socket
+ f.rt.DBPath = unit.DBPath
+ source := claimServingLaunchSource(f.backend, f.tokens, func(context.Context, registry.Route) (*hostbox.UnitLaunchInputs, error) { return unit, nil })
+ launch, err := source(t.Context(), f.rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if launch.Unit != unit || launch.Identity.Config != unit.Program || launch.Identity.Release != launch.Staged.Digest || launch.ExecutableSHA256 != launch.Staged.Digest {
+ t.Fatal(launch)
+ }
+ in := ClaimLaunchInputs{Unit: unit, Roles: launch.Roles, Identity: launch.Identity, Executable: launch.Executable, ExecutableSHA256: launch.ExecutableSHA256, Args: launch.Args, Env: launch.Env, DBPath: unit.DBPath, Limit: launch.Limit}
+ if err = shapeAssembly().PreflightLaunch(f.rt.Host, in); err != nil {
+ t.Fatal(err)
+ }
+ for _, change := range []func(*ClaimLaunchInputs){
+ func(in *ClaimLaunchInputs) { in.Args = []string{"shell"} }, func(in *ClaimLaunchInputs) { in.Env = []string{"ROOT_PROGRAM=no"} },
+ func(in *ClaimLaunchInputs) { in.Identity.Config = "different" }, func(in *ClaimLaunchInputs) { in.DBPath = "/data/sibling.db" },
+ } {
+ bad := in
+ change(&bad)
+ if err = shapeAssembly().PreflightLaunch(f.rt.Host, bad); err == nil {
+ t.Fatal("changed bound inputs accepted")
+ }
+ }
+ if _, err = os.Stat(f.envPath()); !os.IsNotExist(err) {
+ t.Fatal("bound selection minted ordinary environment", err)
+ }
+}
diff --git a/internal/host/claimunit_materialize.go b/internal/host/claimunit_materialize.go
new file mode 100644
index 000000000..ab623d23d
--- /dev/null
+++ b/internal/host/claimunit_materialize.go
@@ -0,0 +1,198 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+type claimUnitMaterialization struct {
+ Prior claimUnitBinding
+ PriorIdentity rootstate.FileIdentity
+ Binding claimUnitBinding
+ Digest string
+ Identity rootstate.FileIdentity
+ Retiring, Retired bool
+}
+
+type claimUnitMaterializer struct {
+ idle func(string) error
+ state rootstate.Store
+ read func(string) ([]byte, rootstate.FileIdentity, error)
+ create func(string, []byte) error
+ replace func(string, rootstate.FileIdentity, []byte) error
+}
+
+func claimUnitDigest(b []byte) string { sum := sha256.Sum256(b); return hex.EncodeToString(sum[:]) }
+
+func (m claimUnitMaterializer) load(host string) (claimUnitMaterialization, error) {
+ var r claimUnitMaterialization
+ raw, err := m.state.Read(host + ".unit.json")
+ if err != nil {
+ return r, err
+ }
+ dec := json.NewDecoder(bytes.NewReader(raw))
+ dec.DisallowUnknownFields()
+ if dec.Decode(&r) != nil || dec.Decode(new(any)) != io.EOF || !r.Binding.valid() || r.Binding.Owner.Host != host || !claimHexDigest(r.Digest) || (r.Retired && !r.Retiring) {
+ return r, os.ErrPermission
+ }
+ if r.Prior != (claimUnitBinding{}) && (!r.Prior.valid() || !r.Prior.Retired || r.Prior.Shape != r.Binding.Shape || r.Prior.Owner == r.Binding.Owner || r.PriorIdentity == (rootstate.FileIdentity{})) {
+ return r, os.ErrPermission
+ }
+ return r, nil
+}
+func (m claimUnitMaterializer) save(r claimUnitMaterialization) error {
+ raw, err := json.Marshal(r)
+ if err != nil {
+ return err
+ }
+ return m.state.Write(r.Binding.Owner.Host+".unit.json", raw)
+}
+func claimOwnedUnitDefinition(b claimUnitBinding) ([]byte, error) {
+ raw, err := claimBoundUnitDefinition(b.Shape)
+ if err != nil {
+ return nil, err
+ }
+ owner, _ := json.Marshal(b.Owner)
+ return append([]byte("# claim-owner="+claimUnitDigest(owner)+"\n"), raw...), nil
+}
+func claimRetiredUnitDefinition(b claimUnitBinding) []byte {
+ owner, _ := json.Marshal(b.Owner)
+ return []byte("# claim-retired=" + claimUnitDigest(owner) + "\n[Unit]\nRefuseManualStart=yes\n[Service]\nType=oneshot\nExecStart=/usr/bin/false\nRestart=no\n")
+}
+
+func (m claimUnitMaterializer) install(ctx context.Context, b claimUnitBinding) error {
+ if m.state == nil || m.read == nil || m.create == nil || m.idle == nil || !b.valid() || b.Retiring || b.Retired {
+ return os.ErrPermission
+ }
+ unlock, err := m.state.Lock(ctx, "materialize")
+ if err != nil {
+ return err
+ }
+ defer unlock()
+ want, err := claimOwnedUnitDefinition(b)
+ if err != nil {
+ return err
+ }
+ r, err := m.load(b.Owner.Host)
+ if errors.Is(err, os.ErrNotExist) {
+ if _, _, e := m.read(b.Shape.Unit); !errors.Is(e, os.ErrNotExist) {
+ if e != nil {
+ return e
+ }
+ return os.ErrPermission
+ }
+ r = claimUnitMaterialization{Binding: b, Digest: claimUnitDigest(want)}
+ } else if err != nil {
+ return err
+ }
+ if r.Binding != b || r.Digest != claimUnitDigest(want) || r.Retiring || r.Retired {
+ return os.ErrPermission
+ }
+ if r.Identity == (rootstate.FileIdentity{}) {
+ if err := m.idle(b.Shape.Host); err != nil {
+ return err
+ }
+ }
+ if err = m.save(r); err != nil {
+ return err
+ }
+ raw, id, err := m.read(b.Shape.Unit)
+ if errors.Is(err, os.ErrNotExist) && r.Identity == (rootstate.FileIdentity{}) {
+ if err = m.create(b.Shape.Unit, want); err != nil {
+ return err
+ }
+ raw, id, err = m.read(b.Shape.Unit)
+ }
+ if err != nil {
+ return err
+ }
+ if !bytes.Equal(raw, want) || id == (rootstate.FileIdentity{}) || (r.Identity != (rootstate.FileIdentity{}) && r.Identity != id) {
+ return os.ErrPermission
+ }
+ r.Identity = id
+ return m.save(r)
+}
+
+func (m claimUnitMaterializer) verify(b claimUnitBinding) error {
+ r, err := m.load(b.Owner.Host)
+ if err != nil {
+ return err
+ }
+ if r.Binding != b || r.Retiring || r.Retired || r.Identity == (rootstate.FileIdentity{}) {
+ return os.ErrPermission
+ }
+ raw, id, err := m.read(b.Shape.Unit)
+ if err != nil {
+ return err
+ }
+ want, err := claimOwnedUnitDefinition(b)
+ if err != nil {
+ return err
+ }
+ if id != r.Identity || !bytes.Equal(raw, want) || claimUnitDigest(raw) != r.Digest {
+ return os.ErrPermission
+ }
+ return nil
+}
+
+func (m claimUnitMaterializer) retire(ctx context.Context, b claimUnitBinding, gone func() error) error {
+ if m.state == nil || m.read == nil || m.replace == nil || gone == nil || !b.valid() {
+ return os.ErrPermission
+ }
+ unlock, err := m.state.Lock(ctx, "materialize")
+ if err != nil {
+ return err
+ }
+ defer unlock()
+ r, err := m.load(b.Owner.Host)
+ if err != nil {
+ return err
+ }
+ if r.Binding != b || r.Identity == (rootstate.FileIdentity{}) {
+ return os.ErrPermission
+ }
+ raw, id, err := m.read(b.Shape.Unit)
+ if err != nil {
+ return err
+ }
+ tombstone := claimRetiredUnitDefinition(b)
+ if r.Retiring && bytes.Equal(raw, tombstone) {
+ if r.Retired && r.Identity != id {
+ return os.ErrPermission
+ }
+ r.Identity = id
+ r.Retired = true
+ return m.save(r)
+ }
+ if r.Retired || id != r.Identity || claimUnitDigest(raw) != r.Digest {
+ return os.ErrPermission
+ }
+ r.Retiring = true
+ if err = m.save(r); err != nil {
+ return err
+ }
+ if err = gone(); err != nil {
+ return err
+ }
+ if err = m.replace(b.Shape.Unit, r.Identity, tombstone); err != nil {
+ return err
+ }
+ raw, id, err = m.read(b.Shape.Unit)
+ if err != nil {
+ return err
+ }
+ if !bytes.Equal(raw, tombstone) {
+ return os.ErrPermission
+ }
+ r.Identity = id
+ r.Retired = true
+ return m.save(r)
+}
diff --git a/internal/host/claimunit_materialize_test.go b/internal/host/claimunit_materialize_test.go
new file mode 100644
index 000000000..ae9445d21
--- /dev/null
+++ b/internal/host/claimunit_materialize_test.go
@@ -0,0 +1,127 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+type plan14UnitFiles struct {
+ body []byte
+ id rootstate.FileIdentity
+ creates, retires int
+}
+
+func (f *plan14UnitFiles) read(string) ([]byte, rootstate.FileIdentity, error) {
+ if f.body == nil {
+ return nil, rootstate.FileIdentity{}, os.ErrNotExist
+ }
+ return append([]byte(nil), f.body...), f.id, nil
+}
+func (f *plan14UnitFiles) create(_ string, b []byte) error {
+ if f.body != nil {
+ return os.ErrExist
+ }
+ f.body = append([]byte(nil), b...)
+ f.id = rootstate.FileIdentity{Device: 1, Inode: 2}
+ f.creates++
+ return nil
+}
+func (f *plan14UnitFiles) replace(_ string, id rootstate.FileIdentity, b []byte) error {
+ if id != f.id {
+ return os.ErrPermission
+ }
+ f.body = append([]byte(nil), b...)
+ f.id.Inode++
+ f.retires++
+ return nil
+}
+func TestPlan14UnitMaterialization(t *testing.T) {
+ for _, attack := range []string{"none", "foreign file", "busy install", "replacement", "changed bytes", "lost intent", "lost materialized", "busy", "retired"} {
+ t.Run(attack, func(t *testing.T) {
+ b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "")
+ shape.Program = strings.Repeat("a", 64)
+ b.canonical = func(context.Context, string) (claimUnitShape, error) { return shape, nil }
+ binding, err := b.establish(t.Context(), in.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ files := &plan14UnitFiles{}
+ state := &plan14UnitBindingFault{Store: &rollbackMemory{files: map[string][]byte{}}}
+ m := claimUnitMaterializer{idle: func(string) error { return nil }, state: state, read: files.read, create: files.create, replace: files.replace}
+ if attack == "foreign file" {
+ files.body = []byte("foreign")
+ files.id = rootstate.FileIdentity{Device: 1, Inode: 9}
+ }
+ if attack == "busy install" {
+ m.idle = func(string) error { return os.ErrPermission }
+ }
+ if attack == "lost intent" {
+ state.fail = true
+ }
+ err = m.install(t.Context(), binding)
+ if attack == "foreign file" || attack == "lost intent" || attack == "busy install" {
+ if err == nil || files.creates != 0 {
+ t.Fatal("effect before durable intent", err, files.creates)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if files.creates != 1 {
+ t.Fatal(files.creates)
+ }
+ if err = m.install(t.Context(), binding); err != nil || files.creates != 1 {
+ t.Fatal("replay", err)
+ }
+ switch attack {
+ case "replacement":
+ files.id.Inode++
+ case "changed bytes":
+ files.body = append(files.body, 'x')
+ case "lost materialized":
+ record, err := m.load(in.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ record.Identity = rootstate.FileIdentity{}
+ if err = m.save(record); err != nil {
+ t.Fatal(err)
+ }
+ if err = m.install(t.Context(), binding); err != nil || files.creates != 1 {
+ t.Fatal("lost materialization recovery", err)
+ }
+ }
+ gone := func() error {
+ if attack == "busy" {
+ return os.ErrPermission
+ }
+ return nil
+ }
+ err = m.retire(t.Context(), binding, gone)
+ if attack == "replacement" || attack == "changed bytes" || attack == "busy" {
+ if err == nil || files.retires != 0 {
+ t.Fatal("retired foreign or running unit", err)
+ }
+ return
+ }
+ if err != nil || files.retires != 1 {
+ t.Fatal("retire", err, files.retires)
+ }
+ if !strings.Contains(string(files.body), "RefuseManualStart=yes") {
+ t.Fatal("retirement exposed old unit")
+ }
+ if err = m.retire(t.Context(), binding, gone); err != nil || files.retires != 1 {
+ t.Fatal("retire replay", err)
+ }
+ if err = m.install(t.Context(), binding); !errors.Is(err, os.ErrPermission) {
+ t.Fatal("retired unit reinstalled", err)
+ }
+ })
+ }
+}
diff --git a/internal/host/claimunit_production.go b/internal/host/claimunit_production.go
index 8dc7ac57d..96006cf13 100644
--- a/internal/host/claimunit_production.go
+++ b/internal/host/claimunit_production.go
@@ -15,6 +15,10 @@ var openClaimUnitBindings = func() (rootstate.Store, error) {
return rootstate.Open("/var/lib/carlos-claims/unit-bindings", true, false)
}
+var openClaimUnitMaterializations = func() (rootstate.Store, error) {
+ return rootstate.Open("/var/lib/carlos-claims/unit-materializations", true, false)
+}
+
var productionClaimUnitShapes = func() (map[string]claimUnitShape, error) {
shapes, err := readClaimUnitShapes("/etc/carlos/claim-units.json")
if errors.Is(err, os.ErrNotExist) {
@@ -58,7 +62,7 @@ func bindProductionClaimUnits(ctx context.Context, h *ClaimRootWithLaunch, a *Cl
}
shape, ok := current[host]
if !ok {
- return claimUnitShape{}, os.ErrPermission
+ return claimUnitShape{}, os.ErrNotExist
}
return shape, nil
}
@@ -84,23 +88,85 @@ func bindProductionClaimUnits(ctx context.Context, h *ClaimRootWithLaunch, a *Cl
}
return in, nil
}
+ materialState, err := openClaimUnitMaterializations()
+ if err != nil {
+ state.Close()
+ return err
+ }
+ files := claimUnitFiles{dir: "/etc/systemd/system"}
+ material := claimUnitMaterializer{idle: func(host string) error {
+ binding, err := b.read(host)
+ if err != nil {
+ return err
+ }
+ unit := base
+ unit.BoundHost, unit.BoundUnit = host, binding.Shape.Unit
+ return unit.Gone(ctx, host, "")
+ }, state: materialState, read: files.read, create: files.create, replace: files.replace}
+ b.handover = material.handover
hosts := make([]string, 0, len(shapes))
for host := range shapes {
hosts = append(hosts, host)
}
sort.Strings(hosts)
for _, host := range hosts {
- if err := guard.WithLocal(ctx, host, func() error { _, err := b.establish(ctx, host); return err }); err != nil {
+ if err := guard.WithLocal(ctx, host, func() error {
+ binding, err := b.establish(ctx, host)
+ if err != nil {
+ return err
+ }
+ if err := material.install(ctx, binding); err != nil {
+ return err
+ }
+ return projectClaimBoundSocket(binding.Shape)
+ }); err != nil {
+ state.Close()
+ materialState.Close()
+ return err
+ }
+ }
+ if len(hosts) != 0 {
+ if _, err := base.Systemctl(ctx, "daemon-reload"); err != nil {
state.Close()
+ materialState.Close()
return err
}
}
- dispatch := claimUnitDispatch{base: base, bindings: b, shapes: productionClaimUnitShapes, definition: verifyClaimBoundUnitDefinition}
+ dispatch := claimUnitDispatch{base: base, bindings: b, shapes: productionClaimUnitShapes, definition: func(shape claimUnitShape) error {
+ binding, err := b.read(shape.Host)
+ if err != nil {
+ return err
+ }
+ if binding.Shape != shape {
+ return os.ErrPermission
+ }
+ return material.verify(binding)
+ }}
h.Launch.Units, h.Launch.unitBindings = dispatch, b
h.Launch.unitProgram = readClaimUnitProgram
+ h.Launch.boundReady = func(ctx context.Context, started instancectl.ClaimUnitStarted) error {
+ for host, shape := range shapes {
+ if shape.Unit == started.Unit {
+ binding, err := b.check(ctx, host, nil)
+ if err != nil {
+ return err
+ }
+ return claimBoundUnitReady(ctx, started, binding.Shape)
+ }
+ }
+ return nil
+ }
+ h.Root.retireBoundUnit = func(ctx context.Context, target hostguard.FenceTarget) error {
+ if err := retireClaimBoundUnit(ctx, target, b, material, dispatch.Gone); err != nil {
+ return err
+ }
+ _, err := base.Systemctl(ctx, "daemon-reload")
+ return err
+ }
kernel, ok := h.Root.Stop.Coordinator.Kernel.(claimUnitStopKernel)
if !ok {
state.Close()
+ materialState.Close()
return os.ErrPermission
}
kernel.Units = dispatch
@@ -114,7 +180,7 @@ func bindProductionClaimUnits(ctx context.Context, h *ClaimRootWithLaunch, a *Cl
if closePrevious != nil {
err = closePrevious(ctx)
}
- return errors.Join(err, state.Close())
+ return errors.Join(err, state.Close(), materialState.Close())
}
return nil
}
diff --git a/internal/host/claimunit_program.go b/internal/host/claimunit_program.go
index cac2be0d8..f85e17f81 100644
--- a/internal/host/claimunit_program.go
+++ b/internal/host/claimunit_program.go
@@ -92,7 +92,7 @@ func (h *ClaimLaunchRoot) boundLaunchSpec(q hostbox.RoleLaunchRequest) (instance
if err != nil {
return launch, true, err
}
- if !slices.Equal(p.Args, q.Spec.Args) || !slices.Equal(p.Env, q.Spec.Env) {
+ if q.Spec.Identity.Config != b.Shape.Program || !slices.Equal(p.Args, q.Spec.Args) || !slices.Equal(p.Env, q.Spec.Env) {
return launch, true, os.ErrPermission
}
return instancectl.Launch{Schema: instancectl.LaunchSchema, Host: host, Argv: append([]string{filepath.Join(instancectl.LiveDir, host)}, p.Args...), Env: append([]string(nil), p.Env...)}, true, nil
diff --git a/internal/host/claimunit_program_test.go b/internal/host/claimunit_program_test.go
index e93ddae6b..99d9ed934 100644
--- a/internal/host/claimunit_program_test.go
+++ b/internal/host/claimunit_program_test.go
@@ -31,6 +31,7 @@ func TestPlan14BoundProgram(t *testing.T) {
f.root.unitProgram = func(shape claimUnitShape) (claimUnitProgram, error) { return decodeClaimUnitProgram(raw, shape) }
f.root.Units = plan14NamedLaunchUnits{f.units, in.Host, s.Unit}
f.req.Spec.Args, f.req.Spec.Env = p.Args, p.Env
+ f.req.Spec.Identity.Config = s.Program
launch, err := f.root.launchSpec(f.req)
if err != nil || len(launch.Argv) != 4 || launch.Argv[1] != "console" {
t.Fatal(launch, err)
@@ -56,6 +57,11 @@ func TestPlan14BoundProgram(t *testing.T) {
t.Fatal("service-selected environment accepted")
}
f.req.Spec.Env = p.Env
+ f.req.Spec.Identity.Config = "forged-config"
+ if _, err := f.root.launchSpec(f.req); err == nil {
+ t.Fatal("service-selected program identity accepted")
+ }
+ f.req.Spec.Identity.Config = s.Program
f.req.Spec.Args = []string{"shell"}
if _, err := f.root.launchSpec(f.req); err == nil {
t.Fatal("service-selected command accepted")
diff --git a/internal/host/claimunit_ready_linux.go b/internal/host/claimunit_ready_linux.go
new file mode 100644
index 000000000..14e26ef17
--- /dev/null
+++ b/internal/host/claimunit_ready_linux.go
@@ -0,0 +1,126 @@
+package host
+
+import (
+ "context"
+ "encoding/binary"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+)
+
+func claimProcTCPAddress(a *net.TCPAddr, ipv6 bool) string {
+ ip := a.IP.To4()
+ if ipv6 {
+ ip = a.IP.To16()
+ }
+ if ip == nil {
+ return ""
+ }
+ var b strings.Builder
+ for i := 0; i < len(ip); i += 4 {
+ fmt.Fprintf(&b, "%08X", binary.NativeEndian.Uint32(ip[i:i+4]))
+ }
+ return fmt.Sprintf("%s:%04X", b.String(), a.Port)
+}
+
+func claimTCPPeer(conn net.Conn, group string) error {
+ local, ok := conn.LocalAddr().(*net.TCPAddr)
+ if !ok {
+ return os.ErrPermission
+ }
+ remote, ok := conn.RemoteAddr().(*net.TCPAddr)
+ if !ok {
+ return os.ErrPermission
+ }
+ pids, err := os.ReadFile(filepath.Join("/sys/fs/cgroup", group, "cgroup.procs"))
+ if err != nil {
+ return err
+ }
+ inodes := map[string]bool{}
+ for _, network := range []string{"tcp", "tcp6"} {
+ raw, err := os.ReadFile("/proc/net/" + network)
+ if err != nil {
+ return err
+ }
+ for _, line := range strings.Split(string(raw), "\n") {
+ fields := strings.Fields(line)
+ if len(fields) > 9 && fields[1] == claimProcTCPAddress(remote, network == "tcp6") && fields[2] == claimProcTCPAddress(local, network == "tcp6") && fields[3] == "01" && fields[9] != "0" {
+ inodes["socket:["+fields[9]+"]"] = true
+ }
+ }
+ }
+ for _, pid := range strings.Fields(string(pids)) {
+ n, err := strconv.Atoi(pid)
+ if err != nil || n <= 1 {
+ return os.ErrPermission
+ }
+ dir := filepath.Join("/proc", pid, "fd")
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return err
+ }
+ for _, entry := range entries {
+ target, err := os.Readlink(filepath.Join(dir, entry.Name()))
+ if err == nil && inodes[target] {
+ return nil
+ }
+ }
+ }
+ return os.ErrPermission
+}
+
+func claimBoundUnitReady(ctx context.Context, s instancectl.ClaimUnitStarted, shape claimUnitShape) error {
+ transport := &http.Transport{DisableKeepAlives: true, DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ network, endpoint := "unix", claimSocketTarget(shape)
+ if shape.Addr != "" {
+ network, endpoint = "tcp", shape.Addr
+ }
+ conn, err := (&net.Dialer{}).DialContext(ctx, network, endpoint)
+ if err != nil {
+ return nil, err
+ }
+ if network == "unix" {
+ err = claimProbePeer(conn, s.MainPID, "0::/"+s.ControlGroup)
+ } else {
+ for until := time.Now().Add(500 * time.Millisecond); ; {
+ err = claimTCPPeer(conn, s.ControlGroup)
+ if err == nil || time.Now().After(until) {
+ break
+ }
+ select {
+ case <-ctx.Done():
+ conn.Close()
+ return nil, ctx.Err()
+ case <-time.After(10 * time.Millisecond):
+ }
+ }
+ }
+ if err != nil {
+ conn.Close()
+ return nil, err
+ }
+ return conn, nil
+ }}
+ defer transport.CloseIdleConnections()
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://carlos/", nil)
+ if err != nil {
+ return err
+ }
+ client := &http.Client{Transport: transport, Timeout: time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ response, err := client.Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+ if response.StatusCode >= 500 {
+ return os.ErrPermission
+ }
+ return nil
+}
diff --git a/internal/host/claimunit_ready_linux_test.go b/internal/host/claimunit_ready_linux_test.go
new file mode 100644
index 000000000..c5bf89df6
--- /dev/null
+++ b/internal/host/claimunit_ready_linux_test.go
@@ -0,0 +1,40 @@
+package host
+
+import (
+ "net"
+ "testing"
+)
+
+func TestPlan14BoundTCPPeerAddress(t *testing.T) {
+ for _, c := range []struct {
+ ip, want string
+ ipv6 bool
+ }{
+ {"127.0.0.1", "0100007F:1F90", false}, {"::1", "00000000000000000000000001000000:1F90", true},
+ } {
+ if got := claimProcTCPAddress(&net.TCPAddr{IP: net.ParseIP(c.ip), Port: 8080}, c.ipv6); got != c.want {
+ t.Fatal(got, c.want)
+ }
+ }
+}
+
+func TestPlan14BoundTCPPeerRefusesForeignGroup(t *testing.T) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ln.Close()
+ client, err := net.Dial("tcp", ln.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer client.Close()
+ server, err := ln.Accept()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer server.Close()
+ if err := claimTCPPeer(client, "plan14-no-such-unit"); err == nil {
+ t.Fatal("foreign group supplied readiness")
+ }
+}
diff --git a/internal/host/claimunit_ready_other.go b/internal/host/claimunit_ready_other.go
new file mode 100644
index 000000000..2581cfd22
--- /dev/null
+++ b/internal/host/claimunit_ready_other.go
@@ -0,0 +1,13 @@
+//go:build !linux
+
+package host
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/instancectl"
+ "os"
+)
+
+func claimBoundUnitReady(context.Context, instancectl.ClaimUnitStarted, claimUnitShape) error {
+ return os.ErrPermission
+}
diff --git a/internal/host/claimunit_retire.go b/internal/host/claimunit_retire.go
new file mode 100644
index 000000000..e973cc531
--- /dev/null
+++ b/internal/host/claimunit_retire.go
@@ -0,0 +1,45 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "os"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostguard"
+)
+
+type claimBoundUnitCleanup struct {
+ inner hostbox.RetirementAuthority
+ retire func(context.Context, hostguard.FenceTarget) error
+}
+
+func (c claimBoundUnitCleanup) WithCleanup(ctx context.Context, t hostguard.FenceTarget, fn func() error) error {
+ return c.inner.WithCleanup(ctx, t, func() error {
+ if err := c.retire(ctx, t); err != nil {
+ return err
+ }
+ return fn()
+ })
+}
+
+func retireClaimBoundUnit(ctx context.Context, t hostguard.FenceTarget, b *claimUnitBindings, m claimUnitMaterializer, gone func(context.Context, string, string) error) error {
+ captured, err := b.read(t.Host)
+ if errors.Is(err, os.ErrNotExist) {
+ // An unconfigured ordinary host has no named placement to retire.
+ if _, e := b.canonical(ctx, t.Host); errors.Is(e, os.ErrNotExist) {
+ return nil
+ }
+ return os.ErrPermission
+ }
+ if err != nil {
+ return err
+ }
+ if captured.Owner.Host != t.Host || captured.Owner.Incarnation != t.Incarnation || captured.Owner.Stamp != t.Stamp || captured.Owner.Box != t.Box || captured.Owner.Enrollment != t.Enrollment || gone == nil {
+ return os.ErrPermission
+ }
+ captured.Retiring, captured.Retired = false, false
+ return b.retire(ctx, captured, func(claimUnitBinding) error {
+ return m.retire(ctx, captured, func() error { return gone(ctx, t.Host, "") })
+ })
+}
diff --git a/internal/host/claimunit_retire_test.go b/internal/host/claimunit_retire_test.go
new file mode 100644
index 000000000..e55094f42
--- /dev/null
+++ b/internal/host/claimunit_retire_test.go
@@ -0,0 +1,77 @@
+package host
+
+import (
+ "context"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func TestPlan14UnitRetirementExactOwner(t *testing.T) {
+ for _, attack := range []string{"none", "successor", "running", "lost replace", "lost final write"} {
+ t.Run(attack, func(t *testing.T) {
+ b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "")
+ shape.Program = strings.Repeat("a", 64)
+ b.canonical = func(context.Context, string) (claimUnitShape, error) { return shape, nil }
+ captured, err := b.establish(t.Context(), in.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ files := &plan14UnitFiles{}
+ ms := &plan14UnitBindingFault{Store: &rollbackMemory{files: map[string][]byte{}}}
+ material := claimUnitMaterializer{idle: func(string) error { return nil }, state: ms, read: files.read, create: files.create, replace: files.replace}
+ if err = material.install(t.Context(), captured); err != nil {
+ t.Fatal(err)
+ }
+ target := hostguard.FenceTarget{Host: in.Host, Incarnation: in.Incarnation, Stamp: in.Stamp, Box: in.Box, Enrollment: in.Enrollment}
+ if attack == "successor" {
+ target.Stamp++
+ }
+ gone := func(context.Context, string, string) error {
+ if attack == "running" {
+ return os.ErrPermission
+ }
+ return nil
+ }
+ original := material.replace
+ material.replace = func(unit string, id rootstate.FileIdentity, body []byte) error {
+ if err := original(unit, id, body); err != nil {
+ return err
+ }
+ if attack == "lost replace" {
+ return os.ErrInvalid
+ }
+ if attack == "lost final write" {
+ ms.fail = true
+ }
+ return nil
+ }
+ err = retireClaimBoundUnit(t.Context(), target, b, material, gone)
+ if attack == "none" {
+ if err != nil {
+ t.Fatal(err)
+ }
+ } else if err == nil {
+ t.Fatal("attack accepted")
+ }
+ if attack == "successor" || attack == "running" {
+ if files.retires != 0 {
+ t.Fatal("foreign or running retired")
+ }
+ return
+ }
+ material.replace = original
+ ms.fail = false
+ if err = retireClaimBoundUnit(t.Context(), target, b, material, gone); err != nil {
+ t.Fatal("recover", err)
+ }
+ retired, err := b.read(in.Host)
+ if err != nil || !retired.Retired || files.retires != 1 {
+ t.Fatal(retired, err, files.retires)
+ }
+ })
+ }
+}
diff --git a/internal/host/claimwakeassembly.go b/internal/host/claimwakeassembly.go
index b5eb48b44..ec73a92e4 100644
--- a/internal/host/claimwakeassembly.go
+++ b/internal/host/claimwakeassembly.go
@@ -27,6 +27,7 @@ import (
// ClaimLaunchInputs is an in-process adapter result, never a new persisted
// held-input format. The moves adapter retains its MoveInputs and manifest.
type ClaimLaunchInputs struct {
+ Unit *hostbox.UnitLaunchInputs
Roles []string
Identity hostwrite.Identity
Baseline *genselect.Plan
@@ -248,7 +249,7 @@ func (w *ClaimWakeAssembly) Build(ctx context.Context, source hostbox.WorkSource
if err := w.admitsOperation(o, p, writer.Box); err != nil {
return err
}
- if rt.Seed != "" || rt.Backing != "" || rt.Unit != "" || rt.IsSidecar() || rt.Tunnel != "" {
+ if rt.Seed != "" || rt.IsSidecar() || rt.Tunnel != "" {
return hostbox.ErrUnsupported
}
prior, _, err := w.pendingRecovery(call, o)
@@ -259,6 +260,9 @@ func (w *ClaimWakeAssembly) Build(ctx context.Context, source hostbox.WorkSource
if err != nil {
return err
}
+ if (rt.Unit != "" || rt.Backing != "") && in.Unit == nil {
+ return hostbox.ErrUnsupported
+ }
if len(in.Roles) != 1 || in.Roles[0] != "main" || in.Executable == "" || len(in.ExecutableSHA256) != 64 || in.DBPath == "" || in.Identity.Config == "" || in.Identity.Release == "" || in.Admission.Mode != "cold" || (in.Baseline == nil && !in.Empty) {
return hostbox.ErrUnsupported
}
@@ -906,6 +910,9 @@ func (w *ClaimWakeAssembly) readiness(o hostclaim.Observed, p activator.StartPol
launch := a.launch
a.mu.Unlock()
if launch != nil && launch.Root != nil {
+ if in.Unit != nil {
+ return nil
+ }
return claimPollReady(ctx, rt.Socket, "http://carlos/", func(conn net.Conn) error { return launch.PeerInRole(ctx, conn, cmd) }, func(status int) bool { return status < 500 })
}
return w.Ready(ctx, cmd, rt, in)
diff --git a/internal/host/plan14_installed_launch_root_linux_test.go b/internal/host/plan14_installed_launch_root_linux_test.go
index 5ae46f185..2cc335811 100644
--- a/internal/host/plan14_installed_launch_root_linux_test.go
+++ b/internal/host/plan14_installed_launch_root_linux_test.go
@@ -76,7 +76,15 @@ func TestPlan14BoundRouteProgramRootRunner(t *testing.T) {
}
}
+func TestPlan14MaterializedBoundRouteRootRunner(t *testing.T) {
+ for _, unit := range []string{"tcp", "socket-program", "backing-program"} {
+ t.Run(unit, func(t *testing.T) { plan14InstalledClaimLaunch(t, "materialized:"+unit) })
+ }
+}
+
func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
+ materialized := strings.HasPrefix(boundUnit, "materialized:")
+ boundUnit = strings.TrimPrefix(boundUnit, "materialized:")
programMode := boundUnit == "tcp" || boundUnit == "socket-program" || boundUnit == "backing-program"
tcpMode := boundUnit == "tcp" || boundUnit == "backing-program"
if boundUnit == "tcp" {
@@ -136,7 +144,11 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
endpoint = ln.Addr().String()
ln.Close()
}
- program := claimUnitProgram{Schema: 1, Host: host, Args: []string{"plan14-tenant"}, Env: []string{"PLAN14_TENANT_SOCKET=" + filepath.Join(provision.InstanceRunDir(host), "app.sock")}}
+ socketEndpoint := filepath.Join(provision.InstanceRunDir(host), "app.sock")
+ if materialized && !tcpMode {
+ socketEndpoint = "/run/carlos/woodstar-test.sock"
+ }
+ program := claimUnitProgram{Schema: 1, Host: host, Args: []string{"plan14-tenant"}, Env: []string{"PLAN14_TENANT_SOCKET=" + socketEndpoint}}
if tcpMode {
program.Env = append(program.Env, "PLAN14_TENANT_ADDR="+endpoint)
}
@@ -155,6 +167,9 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
t.Fatal(err)
}
}
+ if materialized {
+ plan14MaterializedDependencies(t, ctl)
+ }
template, err := unitsFS.ReadFile("units/carlos-instance@.service")
if err != nil {
t.Fatal(err)
@@ -187,14 +202,19 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
}
f.req.Reservation.Group.RelativePath = instancectl.BoundClaimUnitControlGroup(host, unit)
}
- out, err := os.OpenFile(unitPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
- if err != nil {
- t.Fatal(err)
+ if materialized {
+ unitPath = filepath.Join("/etc/systemd/system", unit)
}
- if _, err := out.WriteString(strings.Join(lines, "\n")); err != nil {
- t.Fatal(err)
+ if !materialized {
+ out, err := os.OpenFile(unitPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := out.WriteString(strings.Join(lines, "\n")); err != nil {
+ t.Fatal(err)
+ }
+ out.Close()
}
- out.Close()
t.Cleanup(func() {
ctl(context.Background(), "stop", unit)
ctl(context.Background(), "stop", instancectl.SliceName(host))
@@ -274,6 +294,9 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
}
if programMode {
shape.Program = hex.EncodeToString(programSum[:])
+ if materialized && !tcpMode {
+ shape.Socket = socketEndpoint
+ }
if tcpMode {
shape.Addr, shape.Socket = endpoint, ""
}
@@ -310,6 +333,29 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
}
return ""
}(), Systemctl: ctl, CgroupRoot: "/sys/fs/cgroup", ExeRoot: "/run/carlos-control/exe", UnitRoot: "/run/systemd/system", DataRoot: "/data", WaitBudget: 20 * time.Second}
+ var material *claimUnitMaterializer
+ if materialized {
+ ms, err := rootstate.Open(filepath.Join(dir, "unit-materializations"), true, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ms.Close()
+ files := claimUnitFiles{dir: "/etc/systemd/system"}
+ material = &claimUnitMaterializer{idle: func(host string) error { return f.root.Units.Gone(t.Context(), host, "") }, state: ms, read: files.read, create: files.create, replace: files.replace}
+ binding, err := f.root.unitBindings.read(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := material.install(t.Context(), binding); err != nil {
+ t.Fatal(err)
+ }
+ if err := projectClaimBoundSocket(binding.Shape); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ctl(t.Context(), "daemon-reload"); err != nil {
+ t.Fatal(err)
+ }
+ }
if programMode {
base := f.root.Units.(instancectl.ClaimUnits)
base.BoundHost, base.BoundUnit = "", ""
@@ -318,6 +364,9 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
t.Fatal(err)
}
f.root.Units = claimUnitDispatch{base: base, bindings: f.root.unitBindings, shapes: func() (map[string]claimUnitShape, error) { return map[string]claimUnitShape{host: binding.Shape}, nil }, definition: func(s claimUnitShape) error {
+ if material != nil {
+ return material.verify(binding)
+ }
// Dependencies and output are runner adapters; all isolation settings
// and the permanent no-reservation refusal remain in the unit.
raw, err := os.ReadFile(unitPath)
@@ -330,6 +379,15 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
return nil
}}
}
+ if materialized {
+ f.root.boundReady = func(ctx context.Context, started instancectl.ClaimUnitStarted) error {
+ b, err := f.root.unitBindings.check(ctx, host, nil)
+ if err != nil {
+ return err
+ }
+ return claimBoundUnitReady(ctx, started, b.Shape)
+ }
+ }
f.root.Kernel = linuxClaimLaunchKernel{OptRoot: "/opt/carlos", CgroupRoot: "/sys/fs/cgroup", ProcRoot: "/proc"}
store := release.NewDirStore(filepath.Join(dir, "deployment"))
rec.Schema = 1
@@ -369,6 +427,19 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
f.req.Spec.Env = []string{"CARLOS_ADMIN_TOKEN=test", "PLAN14_TENANT_SOCKET=" + filepath.Join(provision.InstanceRunDir(host), "app.sock")}
if programMode {
f.req.Spec.Args, f.req.Spec.Env = program.Args, program.Env
+ f.req.Spec.Identity.Config = hex.EncodeToString(programSum[:])
+ if materialized {
+ b, err := f.root.unitBindings.read(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ route := b.Shape.route()
+ out, err := f.root.Serve(t.Context(), f.peer, hostbox.RoleLaunchRequest{V: hostbox.RoleLaunchV, Op: hostbox.RoleLaunchInputs, Route: &route})
+ if err != nil || out.Inputs == nil {
+ t.Fatal("bound admission inputs", err)
+ }
+ f.req.Spec.Args, f.req.Spec.Env = out.Inputs.Args, out.Inputs.Env
+ }
}
inputs, err := f.req.Spec.Hash()
if err != nil {
@@ -400,13 +471,14 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
})
if err != nil {
logs, _ := exec.Command("journalctl", "-u", unit, "--no-pager", "-n", "80").CombinedOutput()
- t.Fatalf("production launch: %v\n%s", err, logs)
+ output, _ := os.ReadFile(provision.LogSocket(host, "run"))
+ t.Fatalf("production launch: %v\n%s\noutput: %s", err, logs, output)
}
transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
if tcpMode {
return (&net.Dialer{}).DialContext(ctx, "tcp", endpoint)
}
- return (&net.Dialer{}).DialContext(ctx, "unix", filepath.Join(provision.InstanceRunDir(host), "app.sock"))
+ return (&net.Dialer{}).DialContext(ctx, "unix", socketEndpoint)
}}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
@@ -420,6 +492,19 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
t.Fatal(resp.Status)
}
checkSibling()
+ if materialized {
+ b, err := f.root.unitBindings.read(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ unrelated := instancectl.ClaimUnitStarted{Unit: response.Grant.Unit, MainPID: response.Grant.PID, ControlGroup: instancectl.ClaimUnitControlGroup("unrelated.example")}
+ if b.Shape.Socket != "" {
+ unrelated.MainPID = os.Getpid()
+ }
+ if err := claimBoundUnitReady(t.Context(), unrelated, b.Shape); err == nil {
+ t.Fatal("readiness accepted unrelated process or group")
+ }
+ }
generations, err := rootstate.Open(filepath.Join(dir, "generations"), false, false)
if err != nil {
t.Fatal(err)
@@ -537,6 +622,70 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
t.Logf("%s", output)
}
checkSibling()
+ if material != nil {
+ original, err := os.ReadFile(unitPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ saved := unitPath + ".plan14-original"
+ if _, err := os.Lstat(saved); !os.IsNotExist(err) {
+ t.Fatal("fixture predecessor exists", err)
+ }
+ if err := os.Rename(unitPath, saved); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Remove(saved) })
+ files := claimUnitFiles{dir: filepath.Dir(unitPath)}
+ if err := files.create(unit, original); err != nil {
+ t.Fatal(err)
+ }
+ if err := retireClaimBoundUnit(t.Context(), f.target, f.root.unitBindings, *material, f.root.Units.Gone); err == nil {
+ t.Fatal("same-byte successor retired")
+ }
+ after, err := os.ReadFile(unitPath)
+ if err != nil || string(after) != string(original) {
+ t.Fatal("successor changed", err)
+ }
+ if err := os.Remove(unitPath); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Rename(saved, unitPath); err != nil {
+ t.Fatal(err)
+ }
+ if err := retireClaimBoundUnit(t.Context(), f.target, f.root.unitBindings, *material, f.root.Units.Gone); err != nil {
+ t.Fatal("unit retirement", err)
+ }
+ if _, err := ctl(t.Context(), "daemon-reload"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ctl(t.Context(), "start", unit); err == nil {
+ t.Fatal("retired unit accepted start")
+ }
+ binding, err := f.root.unitBindings.read(host)
+ if err != nil || !binding.Retired {
+ t.Fatal(binding, err)
+ }
+ for _, leaf := range []string{"launch.json", "permit.json", "exec.sock"} {
+ if _, err := os.Lstat(filepath.Join("/run/carlos-control/exe/.claim", provision.InstanceID(host), leaf)); !os.IsNotExist(err) {
+ t.Fatal("retired environment remains", leaf, err)
+ }
+ }
+ // The root installation/claim source remains a fixture here. The durable
+ // file transition and old-owner refusal use the real production adapters.
+ f.root.unitBindings.handover = material.handover
+ old.Stamp++
+ successor, err := f.root.unitBindings.establish(t.Context(), host)
+ if err != nil {
+ t.Fatal("successor binding", err)
+ }
+ if err := material.verify(successor); err != nil {
+ t.Fatal("successor definition", err)
+ }
+ if err := retireClaimBoundUnit(t.Context(), f.target, f.root.unitBindings, *material, f.root.Units.Gone); err == nil {
+ t.Fatal("old owner retired successor")
+ }
+ checkSibling()
+ }
}
func plan14InstalledRetire(t *testing.T, dir string) {
diff --git a/internal/host/plan14_materialized_unit_root_linux_test.go b/internal/host/plan14_materialized_unit_root_linux_test.go
new file mode 100644
index 000000000..4c59fdbd5
--- /dev/null
+++ b/internal/host/plan14_materialized_unit_root_linux_test.go
@@ -0,0 +1,39 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func plan14MaterializedDependencies(t *testing.T, ctl func(context.Context, ...string) (string, error)) {
+ t.Helper()
+ units := []struct{ name, body string }{
+ {"carlos-edge.service", "[Service]\nType=simple\nExecStart=/bin/sleep infinity\n"},
+ {"data-instances.mount", "[Mount]\nWhat=/data/instances\nWhere=/data/instances\nType=none\nOptions=bind,nosymfollow\n"},
+ {"run-carlos-instances.mount", "[Mount]\nWhat=/run/carlos/instances\nWhere=/run/carlos/instances\nType=none\nOptions=bind,nosymfollow\n"},
+ {"run-carlos-logs.mount", "[Mount]\nWhat=/run/carlos/logs\nWhere=/run/carlos/logs\nType=none\nOptions=bind,nosymfollow\n"},
+ }
+ for _, u := range units {
+ path := filepath.Join("/run/systemd/system", u.name)
+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = f.WriteString(u.body); err != nil {
+ t.Fatal(err)
+ }
+ f.Close()
+ t.Cleanup(func() {
+ ctl(context.Background(), "stop", u.name)
+ os.Remove(path)
+ ctl(context.Background(), "daemon-reload")
+ })
+ }
+ if out, err := ctl(t.Context(), "daemon-reload"); err != nil {
+ t.Fatal(out, err)
+ }
+}
diff --git a/internal/host/plan14_root_generations_test.go b/internal/host/plan14_root_generations_test.go
index 15d24415c..0faa04bbd 100644
--- a/internal/host/plan14_root_generations_test.go
+++ b/internal/host/plan14_root_generations_test.go
@@ -310,10 +310,16 @@ func TestPlan14GenerationReservationProcessCrash(t *testing.T) {
}
func TestPlan14GenerationProductionWiring(t *testing.T) {
+ t.Setenv("PATH", t.TempDir())
oldOpen, oldShapes := openClaimUnitBindings, productionClaimUnitShapes
+ oldMaterial := openClaimUnitMaterializations
openClaimUnitBindings = func() (rootstate.Store, error) { return &rollbackMemory{files: map[string][]byte{}}, nil }
+ openClaimUnitMaterializations = func() (rootstate.Store, error) { return &rollbackMemory{files: map[string][]byte{}}, nil }
productionClaimUnitShapes = func() (map[string]claimUnitShape, error) { return map[string]claimUnitShape{}, nil }
- t.Cleanup(func() { openClaimUnitBindings, productionClaimUnitShapes = oldOpen, oldShapes })
+ t.Cleanup(func() {
+ openClaimUnitBindings, productionClaimUnitShapes = oldOpen, oldShapes
+ openClaimUnitMaterializations = oldMaterial
+ })
f, _, _ := generationLaunchFixture(t)
root := ClaimRootHTTP{Launches: f.journal, Stop: ClaimStopHTTP{Authenticate: func(context.Context, net.Conn) (StopPeer, error) { return f.peer, nil }, Coordinator: ClaimStopCoordinator{Kernel: stopKernelFunc(nil), Guard: f.root.Guard}}}
assembly := &ClaimAssembly{Claims: f.root.generations.store, ManagedRoots: map[string]string{"opt": f.root.generations.optDir, "data": "/data"}, Local: f.root.Local}
diff --git a/internal/hostbox/rolelaunch.go b/internal/hostbox/rolelaunch.go
index e1026c611..b9615be8c 100644
--- a/internal/hostbox/rolelaunch.go
+++ b/internal/hostbox/rolelaunch.go
@@ -11,6 +11,7 @@ import (
"strings"
"github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/registry"
)
// The reservation-bearing extension of main's isolated root launch protocol
@@ -27,6 +28,7 @@ const (
RoleLaunchCapability = "capability"
RoleLaunchStart = "start"
RoleLaunchStop = "stop"
+ RoleLaunchInputs = "unit-inputs"
// Unit reservation states. Only root writes them, in its local launch
// journal. None of the agent's journal operations moves a unit
@@ -47,6 +49,7 @@ var ErrRoleLaunchShape = fmt.Errorf("%w: role launch shape is not carried by the
// agent asks root to run, the build it pinned and the slice tier; stop names
// the same reservation; capability carries nothing.
type RoleLaunchRequest struct {
+ Route *registry.Route `json:"route,omitempty"`
Kind string `json:"kind,omitempty"`
Spec *LaunchSpec `json:"spec,omitempty"`
V int `json:"v"`
@@ -58,10 +61,16 @@ type RoleLaunchRequest struct {
// RoleLaunchResponse answers every call with the protocol root speaks. A
// start answers with the grant root journaled.
+type UnitLaunchInputs struct {
+ Unit, Socket, Addr, DBPath, Program string
+ Args, Env []string
+}
+
type RoleLaunchResponse struct {
- V int `json:"v"`
- Protocol string `json:"protocol"`
- Grant *RoleLaunchGrant `json:"grant,omitempty"`
+ Inputs *UnitLaunchInputs `json:"inputs,omitempty"`
+ V int `json:"v"`
+ Protocol string `json:"protocol"`
+ Grant *RoleLaunchGrant `json:"grant,omitempty"`
}
// RoleLaunchGrant is root's record of the unit it started for a reservation:
@@ -96,7 +105,14 @@ func (q RoleLaunchRequest) Validate() error {
if q.V != RoleLaunchV {
return ErrRoleLaunchShape
}
+ if q.Op != RoleLaunchInputs && q.Route != nil {
+ return ErrRoleLaunchShape
+ }
switch q.Op {
+ case RoleLaunchInputs:
+ if q.Route == nil || !component(q.Route.Host) || q.Kind != "" || q.Spec != nil || q.Reservation != nil || q.Executable != "" || q.MemoryMax != 0 {
+ return ErrRoleLaunchShape
+ }
case RoleLaunchCapability:
if q.Spec != nil || q.Reservation != nil || q.Executable != "" || q.MemoryMax != 0 {
return ErrRoleLaunchShape
diff --git a/internal/hostbox/rolelaunch_inputs_test.go b/internal/hostbox/rolelaunch_inputs_test.go
new file mode 100644
index 000000000..d12c42ce1
--- /dev/null
+++ b/internal/hostbox/rolelaunch_inputs_test.go
@@ -0,0 +1,30 @@
+package hostbox
+
+import (
+ "bytes"
+ "github.com/carlosframework/platform/internal/registry"
+ "testing"
+)
+
+func TestPlan14UnitInputsProtocol(t *testing.T) {
+ q := RoleLaunchRequest{V: RoleLaunchV, Op: RoleLaunchInputs, Route: &registry.Route{Host: "shop.example", Unit: "console.service"}}
+ raw, err := EncodeRoleLaunchRequest(q)
+ if err != nil {
+ t.Fatal(err)
+ }
+ decoded, err := DecodeRoleLaunchRequest(bytes.NewReader(raw))
+ if err != nil || decoded.Route.Unit != q.Route.Unit {
+ t.Fatal(decoded, err)
+ }
+ for _, mutate := range []func(*RoleLaunchRequest){
+ func(q *RoleLaunchRequest) { q.Route = nil }, func(q *RoleLaunchRequest) { q.Op = RoleLaunchCapability },
+ func(q *RoleLaunchRequest) { q.Op = RoleLaunchStop }, func(q *RoleLaunchRequest) { q.MemoryMax = 1 },
+ func(q *RoleLaunchRequest) { q.Executable = "/app" }, func(q *RoleLaunchRequest) { q.Kind = "serving" }, func(q *RoleLaunchRequest) { q.Spec = &LaunchSpec{} },
+ } {
+ bad := q
+ mutate(&bad)
+ if bad.Validate() == nil {
+ t.Fatal("mixed protocol request accepted", bad)
+ }
+ }
+}
diff --git a/internal/provision/testdata/plan14-root-reads-e.tsv b/internal/provision/testdata/plan14-root-reads-e.tsv
index 283b2bfee..e1bf4aebc 100644
--- a/internal/provision/testdata/plan14-root-reads-e.tsv
+++ b/internal/provision/testdata/plan14-root-reads-e.tsv
@@ -54,7 +54,6 @@ internal/release/roothistory_replace.go replacementReconstruction authority E1 S
internal/release/signedhead.go StageSignedHead authority E1 Canonical route is supplied by the root unit verifier. Verifies signed channel, digest ratchet and manifest/artifact before staging; unavailable pin census never authorizes restaging differing bytes. Fixtures: TestPlan14Incident20261006WithLedger and TestPlan14SignedNamedRecoveryAuthority. registry:Account@202,registry:Account@302,registry:Account@403,registry:App@214,registry:App@415,registry:Channel@222,registry:Host@434 ca02f04e55556ba0233dfa0848b0c533a9d9aa06cad6abd33cfd2968b3bd838a
internal/rootstate/link_linux.go ReadTrustedLink authority A,E1 Checks the full root-owned parent chain before descriptor-based live-link reading. Fixture: TestPlan14TrustedLiveLink; privileged acceptance remains required. file:Open@156 90f8e8de4e2c71f07b5d749cbe4bb1c1dfa426576a82138823449f56fc9ec5ab
internal/rootstate/link_linux.go directory.readLink authority A,E1 Pins the symlink inode with O_PATH and checks owner/type/link count before readlinkat. Fixture: TestPlan14TrustedLiveLink. file:Fstat@356,file:Openat@183,file:Readlinkat@554 a8559fe63dce48830916004ecfe7fbbe5d419568c7daf7a3073cf1b5bb12c3d7
-@coverage internal/host 20d945384e2acd968e65c69ad218b960dc1428993a3b281092aac9390a4b90a7
@coverage internal/release 329e742eddf1a5da3cd8f4a6b76a2eda2e38e5e6f0132d66e7b3830cfa95e77f
@coverage internal/rootstate a773d6dbc0e4150e0054f7a94d0e1493c88d0d57f4f4530138b3427e2c217bc5
internal/release/roothistory_status.go RootHistory.MigrationStatus authority E1 Exact completion binds the accepted authorization digest from durable private history to enrolled-root signed readback. Pending originals, changed evidence and canonical sources cannot supply retirement completion. Fixtures: TestPlan14HistoryReconstructionCompletion, TestPlan14HistoryRetirementCompletion, TestPlan14ReadReconstructionCompletion, TestPlan14ReadRetirementCompletion, TestPlan14RecoveryRetirementProductionCallback, TestPlan14RootHistoryReconstructionCrash, TestPlan14HistoryRetirementProcessCrash. file:Read@1062,file:Read@3891 f0ab7ad2d2d70f8c22ebfd575f5a1c6931f7eba30ccdba80ad43d0efffc88154
@@ -159,7 +158,6 @@ internal/host/rootrestart_requests_source.go rootRestartRequestSource.inventory
internal/host/rootrestart_requests_source.go rootRestartRequestSource.target authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. registry:Unit@1211,whole:Route@891 301b62efbf0281f713cbed7fb15cd244b7d2c479882f4ad930c8d482b51177ff
internal/host/rootrestart_requests_source.go type rootRestartRequestSource authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. db7ff5c46b6db3fe88657fddf8b4e17324b896fce9b21042d7f0f63140c1d776
internal/release/rootrestart_history.go RootRestarts.Bindings authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. file:Read@324 d22b7e6b5710ca0025639395101933bec5ce0bd38768ebcf44a0805f1f159b08
-internal/host/claimretire_transport.go type ClaimRootHTTP authority E1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. e87bb168975b9a7c4334de1c1fc027c556d69597122a6134770e770c719a45be
internal/host/claimunit.go claimUnitContent authority E1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. 38dcfeb754659895bc28b024bc3186c6b12edeb17d087546507fa1299eb8e331
internal/host/roothistory_creation.go productionCreationHistory authority E1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending. file:Open@289,file:Open@368 a26026dd565e4d1d046591fa1534ded1b64ccb8eafbef65a4d4ec899c0c471d3
internal/host/roothistory_creation.go rootHistoryCreation authority E1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. file:Open@190,file:Open@300,whole:Route@535 49c28b2886b2ef03b818b2ad7d9a7f6fec65637f86e4d2f94e54394cac032bcc
@@ -188,7 +186,6 @@ internal/host/claimlaunch_production.go WithProductionClaimLaunch authority E1,E
internal/host/claimlaunch_root.go ClaimLaunchRoot.launchLocked authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. file:Read@2348,file:Read@2507,file:Read@707 8857198d935629758593ec447afac75c9b28ecaf09743bb8f304f93ea868c66e
internal/host/claimlaunch_root.go ClaimLaunchRoot.start authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 78796fbb8f2516f96d1de84cfdd97f294cbcbd21ee63e8b47aa4e2fc2bad3980
internal/host/claimlaunch_root.go ClaimLaunchRoot.startRecorded authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. file:Read@2865 f0bc2975c60b0476090e265227b6428b3e7ab0bb41c6a0feb9a9a2244642fbc9
-internal/host/claimlaunch_root.go type ClaimLaunchRoot authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. f1a0ee3e297b8f85b63f0f5361d065d3cd21448552f1574ec54f6256409fe3f3
internal/host/rootgenerations_launch.go generationLaunchPermit.check authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 788e600dd37fec60c5b98dc42b23c182c337df064088a041e2e20b68573942b5
internal/host/rootgenerations_launch.go generationLaunchPermit.started authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. PROPOSED E45: root uses retained reservation identities and live kernel containment checks; unrecognized processes still refuse. Current positive root acceptance requires rerun after proc-mount tightening. a7635d02d9cb682be64a0df3f8932336f5e874260898df9013ff7ffd7e3d7462
internal/host/rootgenerations_launch.go productionRootGenerations authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. file:Open@346 a1132403ace7213de9e12720d5a393a3bb85045d1d2c53f0c37ea2ae8e933b04
@@ -295,7 +292,6 @@ internal/host/rootgeneration_census_linux.go generationKernel.census authority E
internal/host/rootgeneration_inventory.go generationInventoryAt authority E2 E26: checked read-only payload enumeration includes inventory and revision orphans; completeness binds current and retained predecessor inventories, exact current revision and both source reads. Fixtures: TestPlan14GenerationPayloadCompleteness, TestPlan14GenerationPayloadRetainedPredecessor, TestPlan14GenerationInventoryPinConsumer, TestPlan14GenerationPayloadInventory, TestPlan14GenerationPayloadReaderDoesNotInitialize. Checked root reader acceptance passed at E27; running-tenant containment remains unfinished. PROPOSED E43: root; release-held context and D3 reservation/census verification under release-before-participation exclusion; no-census effects stay on the legacy path. TestPlan14LaunchVerifierToken, TestPlan14LaunchVerifierCallerOrder, TestPlan14LaunchVerifierNoCensus, TestPlan14LaunchVerifierRootRunner. file:Open@173,file:Open@533 57f4c32cd65afcefad93a41d5266e2b12acc624ff808f47d40e31a79bbc7a827
internal/hostbox/generationinventory.go LaunchJournal.GenerationLaunches authority E2 E24: independent root installation and launch enumeration, exact reservation reconciliation and fresh quiescent whole-version-tree kernel census; missing sources, open unreserved launches, foreign namespaces and uncontained tasks refuse. Production pin source certifies this evidence before returning pins. Fixtures: TestPlan14GenerationInventoryCompleteness, TestPlan14GenerationInventoryPinConsumer, TestPlan14GenerationInventoryLifecycle, TestPlan14GenerationInventoryProcessCrash, TestPlan14GenerationKernelCensusUnknownReference, TestPlan14GenerationLaunchInventory, TestPlan14GenerationReaderDoesNotInitialize, TestPlan14GenerationInstallationInventory. Checked production source/kernel witnesses passed on the E27 root runner. E29 physical-payload and installed-unit witnesses passed on the controller runner; E30 required-artifact witness pending. file:QueryContext@270 6694bd3a35b8d91f53e67134145c44854086f08593110ed1b905bca9587be290
internal/hostbox/generationinventory.go OpenGenerationLaunchReader authority E2 E24: independent root installation and launch enumeration, exact reservation reconciliation and fresh quiescent whole-version-tree kernel census; missing sources, open unreserved launches, foreign namespaces and uncontained tasks refuse. Production pin source certifies this evidence before returning pins. Fixtures: TestPlan14GenerationInventoryCompleteness, TestPlan14GenerationInventoryPinConsumer, TestPlan14GenerationInventoryLifecycle, TestPlan14GenerationInventoryProcessCrash, TestPlan14GenerationKernelCensusUnknownReference, TestPlan14GenerationLaunchInventory, TestPlan14GenerationReaderDoesNotInitialize, TestPlan14GenerationInstallationInventory. Checked production source/kernel witnesses passed on the E27 root runner. E29 physical-payload and installed-unit witnesses passed on the controller runner; E30 required-artifact witness pending. file:Lstat@218,file:Open@283 552d8629a7932c4ae5dca9d2beef4559f7e8a2864259d11e2ff98c699fdcd999
-@coverage internal/hostbox b76083441c876c66209ec032b57e7dde6eb51dd41597f9d2d5462dffa3736abe
internal/host/rootgeneration_census_linux.go productionGenerationKernelCensus authority E2 E24: independent root installation and launch enumeration, exact reservation reconciliation and fresh quiescent whole-version-tree kernel census; missing sources, open unreserved launches, foreign namespaces and uncontained tasks refuse. Production pin source certifies this evidence before returning pins. Fixtures: TestPlan14GenerationInventoryCompleteness, TestPlan14GenerationInventoryPinConsumer, TestPlan14GenerationInventoryLifecycle, TestPlan14GenerationInventoryProcessCrash, TestPlan14GenerationKernelCensusUnknownReference, TestPlan14GenerationLaunchInventory, TestPlan14GenerationReaderDoesNotInitialize, TestPlan14GenerationInstallationInventory. Checked production source/kernel witnesses passed on the E27 root runner. E29 physical-payload and installed-unit witnesses passed on the controller runner; E30 required-artifact witness pending. PROPOSED E45: root uses retained reservation identities and live kernel containment checks; unrecognized processes still refuse. Current positive root acceptance requires rerun after proc-mount tightening. e0c2041104c3a10eebc0183e67602069fd33954f8d0c8efb058261cd54551900
internal/host/rootgeneration_census_other.go productionGenerationKernelCensus authority E2 E24: independent root installation and launch enumeration, exact reservation reconciliation and fresh quiescent whole-version-tree kernel census; missing sources, open unreserved launches, foreign namespaces and uncontained tasks refuse. Production pin source certifies this evidence before returning pins. Fixtures: TestPlan14GenerationInventoryCompleteness, TestPlan14GenerationInventoryPinConsumer, TestPlan14GenerationInventoryLifecycle, TestPlan14GenerationInventoryProcessCrash, TestPlan14GenerationKernelCensusUnknownReference, TestPlan14GenerationLaunchInventory, TestPlan14GenerationReaderDoesNotInitialize, TestPlan14GenerationInstallationInventory. Checked production source/kernel witnesses passed on the E27 root runner. E29 physical-payload and installed-unit witnesses passed on the controller runner; E30 required-artifact witness pending. a1217cf881e9d05336fc16d6050f7f7a1a2184a0c20ff2988d0cf0f9012eaa45
internal/host/rootgeneration_inventory.go generationInstallation authority E2 E24: independent root installation and launch enumeration, exact reservation reconciliation and fresh quiescent whole-version-tree kernel census; missing sources, open unreserved launches, foreign namespaces and uncontained tasks refuse. Production pin source certifies this evidence before returning pins. Fixtures: TestPlan14GenerationInventoryCompleteness, TestPlan14GenerationInventoryPinConsumer, TestPlan14GenerationInventoryLifecycle, TestPlan14GenerationInventoryProcessCrash, TestPlan14GenerationKernelCensusUnknownReference, TestPlan14GenerationLaunchInventory, TestPlan14GenerationReaderDoesNotInitialize, TestPlan14GenerationInstallationInventory. Checked production source/kernel witnesses passed on the E27 root runner. E29 physical-payload and installed-unit witnesses passed on the controller runner; E30 required-artifact witness pending. 3e5e15d50752510acb44b10eb29621757a87bff54ceae015f704dd7f3a848ecb
@@ -346,7 +342,6 @@ internal/hostbox/retirement_timers.go retirementWithTimers authority C,E2 E38: r
internal/activator/activator.go Service.respawnWorkerLocked authority E2 E39: close the unsupported claimed worker path before backend entry, replacement or supervised stop. Claims-off paths remain unchanged. This is refusal coverage, not positive root-reserved worker launch support. Fixtures: TestPlan14GenerationWorkerStartBoundary, TestPlan14GenerationWorkerRealBackendRefusal, TestPlan14GenerationWorkerReplacementBoundary, TestPlan14GenerationWorkerSupervisionBoundary. Pre-flight 29 and E39 worker launch audit. whole:Route@11828,whole:Route@13334,whole:Route@360,whole:Route@4164,whole:Route@6157,whole:Route@6338 706f2a4268a06dae2eca22093f841750139c5bd9dcaa0e91657621c5eea1bbe9
internal/activator/workerbuild.go Service.startWorker authority E2 E39: close the unsupported claimed worker path before backend entry, replacement or supervised stop. Claims-off paths remain unchanged. This is refusal coverage, not positive root-reserved worker launch support. Fixtures: TestPlan14GenerationWorkerStartBoundary, TestPlan14GenerationWorkerRealBackendRefusal, TestPlan14GenerationWorkerReplacementBoundary, TestPlan14GenerationWorkerSupervisionBoundary. Pre-flight 29 and E39 worker launch audit. registry:Host@1201,registry:Host@1379,registry:Host@2314,whole:Route@2420,whole:Route@931 be27c7d049a6009a8a22798569e457f5126f14d5e7c56aafb5b1bd260717f6f5
internal/activator/workerswap.go Service.ReplaceWorker authority E2 E39: close the unsupported claimed worker path before backend entry, replacement or supervised stop. Claims-off paths remain unchanged. This is refusal coverage, not positive root-reserved worker launch support. Fixtures: TestPlan14GenerationWorkerStartBoundary, TestPlan14GenerationWorkerRealBackendRefusal, TestPlan14GenerationWorkerReplacementBoundary, TestPlan14GenerationWorkerSupervisionBoundary. Pre-flight 29 and E39 worker launch audit. whole:Route@2163 2666d54bdc876904d9c2c3d6de5c2f82dc688f07082d92d84ddfc7e01cda6a7e
-@coverage internal/activator 3a1d82354a14988963fec3a1048ac366cf33c939f7dbbc466374635a19370593
internal/activator/backend_exec.go ExecBackend.seedUnit authority E2 E40: claim-enabled raw backend refuses unsupported direct, placed and legacy-control launches before spawn. Claims-off body unchanged. TestPlan14GenerationBackendDirectBoundary, TestPlan14GenerationBackendControlBoundary, TestPlan14GenerationBackendAuxiliaryBoundary; each guard has an independent behavioral mutation. Agent-side refusal is not root reservation or containment evidence. file:Stat@1716,registry:Host@207,whole:Route@1069,whole:Route@1195,whole:Route@239,whole:Route@3935,whole:Route@4065,whole:Route@4600,whole:Route@604,whole:Route@685,whole:Route@751 4a04a9b93fede307dc676ff367a826134e5b754c0ef417214f17439e6304cfda
internal/activator/backend_exec.go ExecBackend.spawnInCgroupGated authority E2 E40: claim-enabled raw backend refuses unsupported direct, placed and legacy-control launches before spawn. Claims-off body unchanged. TestPlan14GenerationBackendDirectBoundary, TestPlan14GenerationBackendControlBoundary, TestPlan14GenerationBackendAuxiliaryBoundary; each guard has an independent behavioral mutation. Agent-side refusal is not root reservation or containment evidence. registry:Host@1033,registry:Host@1096,registry:Host@1302,registry:Host@1401,registry:Host@1457,registry:Host@1530,registry:Host@346,registry:Host@428,registry:Host@450,registry:Host@684,registry:Host@763,registry:Host@831,registry:Host@990,whole:Route@282 10206208862c6f55a8312e1da34a34207719ca71a234eb08852adef7efde0b7a
internal/activator/backend_exec.go ExecBackend.startSidecarUnitFrom authority E2 E40: claim-enabled raw backend refuses unsupported direct, placed and legacy-control launches before spawn. Claims-off body unchanged. TestPlan14GenerationBackendDirectBoundary, TestPlan14GenerationBackendControlBoundary, TestPlan14GenerationBackendAuxiliaryBoundary; each guard has an independent behavioral mutation. Agent-side refusal is not root reservation or containment evidence. registry:Host@1073,registry:Host@1540,registry:Host@1615,registry:Host@1761,registry:Host@1874,registry:Host@1929,registry:Host@992,whole:Route@1495,whole:Route@1656,whole:Route@1964,whole:Route@247,whole:Route@752,whole:Route@838,whole:Route@896 02104c3d98041c64e8b31a1a732baf302f81b848bb223b690f9bb2195a35041a
@@ -373,7 +368,6 @@ internal/host/rootlaunchverifier.go rootLaunchVerifier.verify authority E2 PROPO
internal/rootparticipation/production.go Ledger.ActivationReady authority E2 PROPOSED E43: root; release-held context and D3 reservation/census verification under release-before-participation exclusion; no-census effects stay on the legacy path. TestPlan14LaunchVerifierToken, TestPlan14LaunchVerifierCallerOrder, TestPlan14LaunchVerifierNoCensus, TestPlan14LaunchVerifierRootRunner. 99a3a8f61a8b769da5bce734d6d8a937b861ed38ef1e4f9cc1eb2b4d473286fb
@coverage internal/hostfloor 37459f6d8d47ed5ec3892e22483e34abde2dabc0775cf0846b6fe00fb66dcd75
@coverage internal/rootparticipation d867cfb3ad2b0432f7cee72f29805bc3420e28fe0edd5b89b469f154e4371694
-internal/host/claimretire_transport.go ClaimRootHTTP.retire authority C,G,J1 PROPOSED E44: root timer intention must precede materialization; exact registration and row identity required; root retirement cannot infer ownership from registry enumeration. Tests: TestPlan14TimerIntentionBeforeMaterialization, TestPlan14GenerationTimerRetirement; root timer intention, sync-failure retry and retirement acceptance. Scheduler producer wiring remains incomplete. registry:ClaimOwnership@1081,registry:Host@1062,registry:Host@792,whole:ClaimOwnership@1085,whole:ClaimOwnership@894,whole:Route@1830,whole:Route@854 425059d65adaafa5680ad92974559fbeb56ba0a3a7ff3867bb63937648989e06
internal/host/rootinventory_timer.go ClaimRootHTTP.verifyInventoryTimer hint C,J1 PROPOSED E45 / R-E45-5: root executes a deny-only consistency check of service-writable timer rows. Registry absence is not ordering authority. Root intention authority resides in the retained inventory and fence; producer lifecycle remains incomplete. PROPOSED E46: R-E45-5: service-writable timer SQL is deny-only; root pending/inventory supplies ownership. Executing identity: root instance-control or root participation/update. registry:ClaimIncarnation@1006,registry:ClaimIncarnation@980,registry:ClaimOwner@1072,registry:ClaimOwner@299,registry:ClaimRowID@1107,registry:ClaimStamp@1032,registry:ClaimStamp@1052,registry:Host@936,registry:Name@958,registry:Registration@1144,whole:Timer@709 7af2d533a789e21c0306910c072aa186df0460cddb0d4b5490bbd26b0a19943d
internal/host/rootinventory_timer.go ClaimRootHTTP.verifyNewTimerIntentions hint E2 PROPOSED E45 / R-E45-5: root executes a deny-only consistency check of service-writable timer rows. Registry absence is not ordering authority. Root intention authority resides in the retained inventory and fence; producer lifecycle remains incomplete. PROPOSED E46: R-E45-5: service-writable timer SQL is deny-only; root pending/inventory supplies ownership. Executing identity: root instance-control or root participation/update. whole:Timer@710 43a9e0925e79a484c680f7ca04c1ad19c13cb9863a5e9caf03104259bf9c3ec8
internal/host/claimlaunch_execpermit.go ClaimLaunchRoot.execPermit authority D Root participation bounds the executable permit; TestPlan14ExecutionFinalLaunchUsesRootParticipation covers initial and final checks. PROPOSED E45: root uses retained reservation identities and live kernel containment checks; unrecognized processes still refuse. Current positive root acceptance requires rerun after proc-mount tightening. file:Read@157 cf0aa8db12ea1d451ce4e729572dc8c628ee22453bee25dae62c5ff13e85e769
@@ -440,17 +434,58 @@ internal/instancectl/claimexec.go ClaimUnits.prepareExecGate authority C,G,J2,J4
internal/instancectl/claimunit.go ClaimUnits.cgroupEmpty authority C,G,J2,J4,E2 root-input;kept=C,G;scope=protected-decision-only PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. file:ReadFile@196 9e874fe959a3a381442e2900e8184f91ef803dd4f47839de8dbd6d3ab395d03e
internal/host/claimlaunch_authority.go ClaimLaunchRoot.authorize authority D,E2 Both root launch authorizations read root participation and match authenticated peer; TestPlan14ExecutionFinalLaunchUsesRootParticipation. PROPOSED E47: final named-unit launch authorization rechecks the retained root binding and refuses missing, retired, foreign or changed canonical shape. 9e00346c2012343327a230067f2e35f9f960569fbb3b6ee0dc401e7f566cbef6
internal/host/claimlaunch_authority.go ClaimLaunchRoot.launchSpec authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. 4703055ca1e26021e448839b43b5e281a020e492aac61dadca12f03675a688a4
-internal/host/claimunit_binding.go claimUnitBindings.establish authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. 8001dce0f3c38b1c08845b8a536870e8d644114401670b8ca4950562b3c3517e
internal/host/claimunit_binding.go claimUnitShape.valid authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. 725731eb019d6a4e612a7a94bcebc5619139405081053d725a4c67f763d536d4
-internal/host/claimunit_definition.go claimBoundUnitDefinition authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. file:ReadFile@165 2ef162905f3ca438ac3c186c92cd04059ba4deb07da29badd9d31a02d352ff00
-internal/host/claimunit_definition.go verifyClaimBoundUnitDefinition authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. file:ReadAll@285 107db8628ffc00a68a079040c507484372c36815e20de082f4964caac958597e
internal/host/claimunit_dispatch.go claimUnitDispatch.StartReserved authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. 02dfbc7e31f9a13ba210d2bed3181da5e1874ae388c2d1060cbdb57c398a04e5
internal/host/claimunit_dispatch.go claimUnitDispatch.forStart authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. ca62826e85ad57e88e68739cf35bee81c4caa0e541f1883d4440ebbdaefc4890
internal/host/claimunit_dispatch.go claimUnitDispatch.selected authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. d167dd0c3f8f2b32995fd4d330e9fd3d15054e6abcbc77ce9408a0bf3b2c8196
-internal/host/claimunit_production.go bindProductionClaimUnits authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. file:Read@1270 bf9467dfc3f9819e7157fad6af0ae86c3d0bdb05f56fcd55fb68438e73699712
internal/host/claimunit_production.go var openClaimUnitBindings authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. file:Open@70 f332e5eeef4116b6a9242f7f5c6c47bef78639a042ef0db3993a54dfae71963c
-internal/host/claimunit_program.go ClaimLaunchRoot.boundLaunchSpec authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. e80de9bd3222f06b6a7999ab9aa160a7a5edbb07f58eb7ba6ecba2ef77e66b7e
internal/host/claimunit_program.go readClaimUnitProgram authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. file:ReadAll@353 28ee039bab9f2461f10797082d8cfafe751f094271f377d04565e410127808ec
internal/instancectl/claimexec.go ClaimUnits.pinExecPeer authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. 6966c6e7ca873e312051e94c473f59331b33a1bf6b1017a2a83cd05c66ec01c4
internal/instancectl/claimexec.go claimExecGate.authorize authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. 96964cb1eb39b1dd8ecbd47e9e6139e92a61bc72a83ebacfe9cf5886b6d08712
internal/instancectl/claimunit.go ClaimUnits.start authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. 415efaf2a2a3b304cc9b623c19f0f772e941939322f5752eb8c74ed49e93b1dd
+
+# PROPOSED E49
+internal/activator/rolelaunch_root_linux.go RoleLaunch.startIsolated hint E2 PROPOSED E49: agent (service UID); named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. file:Fstat@2605 a5e700f18823b68fdc17c599e7818b0bc0ffe1dd3d88bd3303693301b325fe78
+internal/host/claimlaunch_root.go ClaimLaunchRoot.Serve authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. whole:Route@665 1f7cc03f7ad8adca38e728c5b3ec5517d40c7915c1deee3efc08acb929412059
+internal/host/claimlaunch_root.go type ClaimLaunchRoot authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. cba734c41024098ee92239f2cca401803b9f7c0fd855065b9285d90189ac3db4
+internal/host/claimlaunch_transport.go ClaimRoleRootClient.UnitInputs hint E2 PROPOSED E49: agent (service UID); named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. whole:Route@226,whole:Route@234 40a7733f6f7684a849c6cfd1e867cc732f9dc15a2d8cd1fff912adc0069f9229
+internal/host/claimowners.go claimServingOwners.Select hint E2 PROPOSED E49: agent (service UID); named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. registry:DBPath@1081,registry:DBPath@461,registry:Host@424,whole:Route@555 19654b1aa1b28c4bc0f71601dd176980ab610160f7fcda8f628d5de5835dc131
+internal/host/claimproduction.go ProductionClaimOwnerProvider.OpenClaimOwners authority E2 PROPOSED E49: agent (service UID); named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. file:Open@4292 a8d876818f5dfb66808c341f1edf88eab6f3f5da6e98a77f7602e4467e6c9cc7
+internal/host/claimproduction.go claimInputPolicy authority E2 PROPOSED E49: root and agent (caller-dependent); named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. registry:Backing@1130,registry:Unit@1113,whole:Route@1096,whole:Route@1203,whole:Route@1437,whole:Route@618,whole:Route@979 14a881c1169490c73f3bc95cba8c7af21386af3761d28dab8281b23d34934c45
+internal/host/claimproduction.go claimServingLaunchSource hint E2 PROPOSED E49: agent (service UID); named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. registry:Host@695,whole:Route@1137,whole:Route@328,whole:Route@457,whole:Route@612,whole:Route@819 43aa8735763509e59253ace1b1718380dcce6171965a6f6a98843ba25ea4b6b8
+internal/host/claimretire_transport.go ClaimRootHTTP.retire authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. registry:ClaimOwnership@1081,registry:Host@1062,registry:Host@792,whole:ClaimOwnership@1085,whole:ClaimOwnership@894,whole:Route@1971,whole:Route@854 38ea70052f78cc0d9fdb95cf1bcbb30cae504c7a833ab45769fedf26978c50c4
+internal/host/claimretire_transport.go type ClaimRootHTTP authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. 674c85dcb180b4818e5c6117cbed7ef7301c8094a9806d854762521d8d41d315
+internal/host/claimroleassembly.go ClaimRoleAssembly.PreflightLaunch hint E2 PROPOSED E49: agent (service UID); named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. data-literal@200 5bf02fb5b6911924dbd18f063fec53552a994bcc9ae790a67db5786fa877de1e
+internal/host/claimroleassembly_linux.go ClaimRoleAssembly.Launch hint E2 PROPOSED E49: agent (service UID); named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. file:Command@5897,file:EvalSymlinks@5446,file:Fstat@3529,file:Lstat@2269,file:OpenFile@3119,file:OpenFile@3306,file:Read@711 6f5cf726038aad19bf8247979e4073d57c804ced0c655cd6e0787b7749ac0d40
+internal/host/claimunit_definition.go claimBoundUnitDefinition authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. file:ReadFile@165 b71194042fe61687350b9dc960744579a8d357d0f66fedc693d8589710f90837
+internal/host/claimunit_definition.go verifyClaimBoundUnitDefinition authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. file:ReadAll@283 41521fe1d039d29440b9dcd067dc202407525912118d8ede300dc4b9ae34795c
+internal/host/claimunit_files_linux.go claimUnitFiles.read authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. file:ReadAll@354 cca15dca6e1d79a12975a567a497008abc6a1e57962ea609631dcdd0d2902760
+internal/host/claimunit_files_linux.go claimUnitFiles.write authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. file:CreateTemp@381,file:Open@182 6367edf5ddca8081479ff9fae8b536dca9df86a265ce0e873269752f5297ef48
+internal/host/claimunit_files_linux.go projectClaimBoundSocket hint E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. file:Readlinkat@433 4f3c8daab1f68102e7be9511d1ec29d89c584c99aed79641f2b740031e503ab4
+internal/host/claimunit_inputs.go ClaimLaunchRoot.boundInputs authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. registry:Backing@483,registry:Host@294,registry:Host@385,registry:Host@651,registry:Tunnel@503,registry:Unit@466,whole:Route@661 52dcc6f753bca0dc33b5f49cfcc7df880c1b1c0121be77c18243eded84dc08e4
+internal/host/claimunit_inputs.go claimBoundSnapshot hint E2 PROPOSED E49: agent (service UID); named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. registry:Host@339,registry:Host@437,registry:Host@728,whole:Route@205 3dca2da123f0cff4415c6ef9ef7102b002452e6dbe3b7f526ded14062c51acca
+internal/host/claimunit_inputs.go validateClaimUnitInputs hint E2 PROPOSED E49: agent (service UID); named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. data-literal@212 3388a4b9a3ea4b26bb2ce8f4a3ab649ffbfed92fd5e3d217bc04bddcc15d32b5
+internal/host/claimunit_production.go var openClaimUnitMaterializations authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. file:Open@78 9946ebde7a32ac9a87a2d2a9966e45e9ae3559da214706bbb4edd2db3089aa3c
+internal/host/claimunit_ready_linux.go claimTCPPeer authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. file:ReadDir@1048,file:ReadFile@241,file:ReadFile@436,file:Readlink@1152 4d30fb4b107ef5768405f689d8b1c40849c8ebe60fe516eb122ef97bf3f9a358
+internal/host/claimwakeassembly.go ClaimWakeAssembly.Build hint E2 PROPOSED E49: agent (service UID); named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. file:Command@9122,file:Prepare@6839,file:Read@1097,file:Read@1191,registry:Backing@2684,registry:Host@11736,registry:Host@3164,registry:Host@3767,registry:Host@3812,registry:Host@4519,registry:Host@5240,registry:Host@6287,registry:Host@6995,registry:Host@7668,registry:Host@8320,registry:Seed@2404,registry:Tunnel@2439,registry:Unit@2667,whole:Route@2611,whole:Route@5640,whole:Route@9260 c3f6ebbf3e063068c5c3322e9edd1eef1f1d09a1024e278e96e77b0e8d1bef15
+internal/host/claimwakeassembly.go ClaimWakeAssembly.readiness hint E2 PROPOSED E49: agent (service UID); named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. registry:Socket@472,whole:Route@1132,whole:Route@653 923fed5500b805bcf31f5d53a5a5fce2859e7bedd68bd22c06b1087ec0a4e635
+internal/hostbox/rolelaunch.go RoleLaunchRequest.Validate hint E2 PROPOSED E49: root/server and agent/client; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. registry:Host@255,whole:Route@136,whole:Route@228,whole:Route@257 ee67fcc3a1d69ee0acb05a32446cc6cd80e97964fe095fc5c83ab4f85935b26c
+@coverage internal/activator 5076bca3ca2fda41c60b04aa927ac99c8756291e6a9b1a1e2417fa35ee81ad24
+@coverage internal/hostbox 0b04568917345170ab505138980c1cb4f0484c146837b81daa44fea12429af0b
+internal/host/claimlaunch_authority.go ClaimLaunchRoot.waitReady authority E2 PROPOSED E49: root; delegated retained binding, exact unit identity and receipt-authorized retirement or per-connection kernel readiness. No service candidate grants authority. 9fa747b55dbd3b9a00b01679bcb973344fba2d9775d50bc4063286e31ee12d3e
+internal/host/claimunit_materialize.go claimUnitMaterializer.install authority E2 PROPOSED E49: root; delegated retained binding, exact unit identity and receipt-authorized retirement or per-connection kernel readiness. No service candidate grants authority. e1159782780b090f5c6adb853eebdb56e05cc1af6595fe13ec96e21e12a8767f
+internal/host/claimunit_materialize.go claimUnitMaterializer.verify authority E2 PROPOSED E49: root; delegated retained binding, exact unit identity and receipt-authorized retirement or per-connection kernel readiness. No service candidate grants authority. 87b28d127a9df0ddacce0856ab6eac8d6967b2dc85d9af32b2938dbaa3ec5ab1
+internal/host/claimunit_materialize.go claimUnitMaterializer.retire authority E2 PROPOSED E49: root; delegated retained binding, exact unit identity and receipt-authorized retirement or per-connection kernel readiness. No service candidate grants authority. 8f65239a6f2d0c826258fe6446f746fabcf060d633f29b694ac2c6ef2045b0d0
+internal/host/claimunit_retire.go retireClaimBoundUnit authority E2 PROPOSED E49: root; delegated retained binding, exact unit identity and receipt-authorized retirement or per-connection kernel readiness. No service candidate grants authority. f7982c0ef43a68af9c0195ecb424a0f58911416a9b422b725eddecc8391f8857
+internal/host/claimunit_retire.go claimBoundUnitCleanup.WithCleanup authority E2 PROPOSED E49: root; delegated retained binding, exact unit identity and receipt-authorized retirement or per-connection kernel readiness. No service candidate grants authority. b1bd5b012973d2abdd29ffcbc65ab6980eef0cbb75caefde7ea62e0738c81b7a
+internal/host/claimunit_ready_linux.go claimBoundUnitReady authority E2 PROPOSED E49: root; delegated retained binding, exact unit identity and receipt-authorized retirement or per-connection kernel readiness. No service candidate grants authority. 62e76b8a9ab27be350f3da6d75224600c6cb02d3259e2bd9efbd52e1df28eb95
+
+# PROPOSED E49
+internal/host/claimunit_binding.go claimUnitBindings.establish authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. 0b414d85d3aa277f24f37eb02186b90b7392585e384e3e253868f84bf1b77ff1
+internal/host/claimunit_materialize.go claimUnitMaterializer.load authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. file:Read@130 0ba6babf0f42abfcc6f8f3ff562f7258211e6405cbbaa8ee90aa987757d3741b
+internal/host/claimunit_program.go ClaimLaunchRoot.boundLaunchSpec authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. 7738f116f2fffb3d2ded6c31c2a7680ab509bba27d52fac85e3ea64f416318f4
+internal/host/claimunit_handover.go claimUnitMaterializer.handover authority E2 PROPOSED E49: root; retained retired predecessor binding and exact tombstone inode authorize only same-shape successor installation. The caller independently resolves the current root installation. 06834223f9165c117262813eebef7a1512d23305c937345f30dec1c938a358e4
+
+# PROPOSED E49
+internal/host/claimunit_production.go bindProductionClaimUnits authority E2 PROPOSED E49: root; named placement/program readback from retained root binding, durable unit intent and inode-conditional retirement, and readiness socket ownership. Service route fields are candidates only; final root launch rechecks independently. No no-census path added. Full always-running adoption/handover remains pending. file:Read@1268 3a422899fe24fa8d00b80e8fe1c902f044f69f1c4f3f3176fea22e6b9ae0ff54
+@coverage internal/host 940f90be9477b700499cf2d21daad88c3d3ed759d87fb7a9b0008a6efa2560f3