carlos / platform Public

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

4a: migration of existing unit definitions and combined unit/env/timer retirement for all three shapes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev e336c7378398d1b65c8fbd36a6cb0fef0f5f45bf parent 05fb1a4
33 files changed, +1837 −166
  • internal/caps/converge.go +16 −0
  • internal/host/agent.go +14 −4
  • internal/host/claimlaunch_authority.go +2 −2
  • internal/host/claimlaunch_root.go +3 −3
  • internal/host/claimproduction.go +34 −13
  • internal/host/claimunit_binding.go +13 −4
  • internal/host/claimunit_definition.go +0 −23
  • internal/host/claimunit_dispatch.go +15 −7
  • internal/host/claimunit_dispatch_test.go +3 −3
  • internal/host/claimunit_dropin_linux.go +51 −0
  • internal/host/claimunit_dropin_other.go +7 −0
  • internal/host/claimunit_effective.go +147 −0
  • internal/host/claimunit_effective_test.go +67 −0
  • internal/host/claimunit_inputs.go +1 −1
  • internal/host/claimunit_materialize.go +24 −11
  • internal/host/claimunit_migrate.go +99 −0
  • internal/host/claimunit_migrate_kernel.go +114 −0
  • internal/host/claimunit_migrate_kernel_test.go +99 −0
  • internal/host/claimunit_migrate_linux.go +61 −0
  • internal/host/claimunit_migrate_other.go +12 −0
  • internal/host/claimunit_migrate_test.go +126 −0
  • internal/host/claimunit_policy.go +194 −0
  • internal/host/claimunit_policy_test.go +219 −0
  • internal/host/claimunit_production.go +90 −50
  • internal/host/claimunit_retire.go +1 −5
  • internal/host/claimunit_startup_test.go +79 −0
  • internal/host/plan14_bound_retirement_root_linux_test.go +139 −0
  • internal/host/plan14_installed_launch_root_linux_test.go +81 −5
  • internal/host/plan14_launch_verifier_test.go +6 −1
  • internal/host/rootrestart.go +11 −1
  • internal/instancectl/claimunit_bound_stop.go +13 −1
  • internal/instancectl/claimunit_bound_test.go +24 −0
  • internal/provision/testdata/plan14-root-reads-e.tsv +72 −32

CI failed — run details

CI log
=== gate ===
go: downloading github.com/aws/aws-sdk-go-v2 v1.44.0
go: downloading github.com/aws/aws-sdk-go-v2/service/sns v1.43.0
go: downloading github.com/aws/aws-sdk-go-v2/config v1.32.6
go: downloading github.com/aws/aws-sdk-go-v2/service/ec2 v1.324.0
go: downloading github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
go: downloading github.com/benbjohnson/litestream v0.5.14
go: downloading modernc.org/sqlite v1.55.0
go: downloading github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1
go: downloading github.com/superfly/ltx v0.5.1
go: downloading github.com/aws/aws-sdk-go-v2/service/acm v1.45.0
go: downloading github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0
go: downloading github.com/aws/aws-sdk-go-v2/service/iam v1.58.2
go: downloading github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.37.0
go: downloading github.com/aws/aws-sdk-go-v2/service/sts v1.45.6
go: downloading github.com/aws/smithy-go v1.28.1
go: downloading golang.org/x/crypto v0.52.0
go: downloading golang.org/x/sys v0.46.0
go: downloading github.com/aws/aws-sdk-go-v2/credentials v1.19.6
go: downloading github.com/pion/logging v0.2.4
go: downloading github.com/pion/stun/v3 v3.0.1
go: downloading github.com/pion/turn/v4 v4.1.4
go: downloading github.com/aws/aws-sdk-go-v2/service/sesv2 v1.66.6
go: downloading github.com/oschwald/maxminddb-golang v1.13.1
go: downloading github.com/klauspost/compress v1.18.0
go: downloading golang.org/x/net v0.55.0
go: downloading github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5
go: downloading github.com/pion/transport/v4 v4.0.1
go: downloading github.com/pion/randutil v0.1.0
go: downloading github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16
go: downloading github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4
go: downloading github.com/aws/aws-sdk-go-v2/service/signin v1.0.4
go: downloading github.com/aws/aws-sdk-go-v2/service/sso v1.30.8
go: downloading github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12
go: downloading github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40
go: downloading github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.40
go: downloading github.com/pion/dtls/v3 v3.0.7
go: downloading github.com/pion/transport/v3 v3.0.8
go: downloading github.com/pierrec/lz4/v4 v4.1.22
go: downloading github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40
go: downloading github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31
go: downloading github.com/prometheus/client_golang v1.17.0
go: downloading golang.org/x/sync v0.21.0
go: downloading github.com/lmittmann/tint v1.1.3
go: downloading github.com/mattn/go-isatty v0.0.20
go: downloading github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18
go: downloading google.golang.org/protobuf v1.36.10
go: downloading github.com/beorn7/perks v1.0.1
go: downloading github.com/cespare/xxhash/v2 v2.3.0
go: downloading github.com/prometheus/procfs v0.12.0
go: downloading github.com/prometheus/client_model v0.5.0
go: downloading github.com/prometheus/common v0.45.0
go: downloading github.com/wlynxg/anet v0.0.5
go: downloading github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0
go: downloading golang.org/x/text v0.37.0
go: downloading modernc.org/libc v1.74.1
go: downloading modernc.org/memory v1.11.0
go: downloading github.com/google/uuid v1.6.0
go: downloading modernc.org/mathutil v1.7.1
go: downloading github.com/dustin/go-humanize v1.0.1
go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
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	285.729s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.864s
ok  	github.com/carlosframework/platform/cmd/moveprobe	93.441s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.706s
ok  	github.com/carlosframework/platform/internal/activator	251.699s
ok  	github.com/carlosframework/platform/internal/agentsock	0.016s
ok  	github.com/carlosframework/platform/internal/alerting	0.003s
ok  	github.com/carlosframework/platform/internal/analytics	0.019s
ok  	github.com/carlosframework/platform/internal/appconfig	2.443s
ok  	github.com/carlosframework/platform/internal/apperr	0.073s
ok  	github.com/carlosframework/platform/internal/applog	0.124s
ok  	github.com/carlosframework/platform/internal/awsinv	0.005s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.616s
?   	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.013s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.703s
ok  	github.com/carlosframework/platform/internal/changelog	0.306s
ok  	github.com/carlosframework/platform/internal/clamd	0.054s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	388.631s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	5.737s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.002s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.326s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.005s
ok  	github.com/carlosframework/platform/internal/edge	27.193s
?   	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.131s
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.018s
ok  	github.com/carlosframework/platform/internal/geoip	0.029s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.151s
ok  	github.com/carlosframework/platform/internal/host	146.373s
ok  	github.com/carlosframework/platform/internal/hostactors	0.288s
ok  	github.com/carlosframework/platform/internal/hostbox	2.403s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.482s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.016s
--- FAIL: TestRenewalRequestStartAndIndependentExpiry (4.01s)
    controller_test.go:211: blocked PUT blocked watchdog
    panic.go:694: context deadline exceeded
FAIL
FAIL	github.com/carlosframework/platform/internal/hostexec	9.705s
ok  	github.com/carlosframework/platform/internal/hostfence	0.028s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.826s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.015s
ok  	github.com/carlosframework/platform/internal/hostguard	0.981s
ok  	github.com/carlosframework/platform/internal/hostops	1.545s
?   	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.434s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.148s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.015s
ok  	github.com/carlosframework/platform/internal/infraview	0.015s
ok  	github.com/carlosframework/platform/internal/instancectl	5.227s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.729s
ok  	github.com/carlosframework/platform/internal/instancefs	0.020s
ok  	github.com/carlosframework/platform/internal/instancekey	0.017s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.024s
ok  	github.com/carlosframework/platform/internal/layout	1.512s
ok  	github.com/carlosframework/platform/internal/lease	9.841s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.022s
ok  	github.com/carlosframework/platform/internal/ledger	0.027s
ok  	github.com/carlosframework/platform/internal/mail	0.181s
ok  	github.com/carlosframework/platform/internal/maintenance	0.092s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.518s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.079s
ok  	github.com/carlosframework/platform/internal/metrics	0.004s
ok  	github.com/carlosframework/platform/internal/moves	21.616s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.754s
ok  	github.com/carlosframework/platform/internal/objstore	0.185s
ok  	github.com/carlosframework/platform/internal/ops	0.171s
ok  	github.com/carlosframework/platform/internal/park	0.194s
ok  	github.com/carlosframework/platform/internal/privatefile	0.003s
ok  	github.com/carlosframework/platform/internal/provision	35.003s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.006s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.701s
ok  	github.com/carlosframework/platform/internal/relay	7.305s
ok  	github.com/carlosframework/platform/internal/release	22.628s
ok  	github.com/carlosframework/platform/internal/rollout	3.760s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.008s
ok  	github.com/carlosframework/platform/internal/rootstate	0.026s
ok  	github.com/carlosframework/platform/internal/runs	0.573s
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.486s
ok  	github.com/carlosframework/platform/internal/seeds	0.010s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	11.395s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.218s
ok  	github.com/carlosframework/platform/internal/status	0.040s
ok  	github.com/carlosframework/platform/internal/steering	0.005s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.236s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.003s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.002s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.561s
FAIL
make: *** [Makefile:18: test] Error 1

amadan: step gate failed: exit status 2

amadan: job failed at step gate — its output is above, not at the tail of this log
diff --git a/internal/caps/converge.go b/internal/caps/converge.go
index c0e1baaae..6fdd5fefa 100644
--- a/internal/caps/converge.go
+++ b/internal/caps/converge.go
@@ -41,6 +41,7 @@ const (
// the converge tests need no systemd, no root, and no /etc — the same seam
// shape activator.UnitBackend.Systemctl uses.
type Ops struct {
+ CheckHost func(string) error
EtcDir string // "" = DefaultEtcDir
StateRoot string // "" = DefaultStateRoot
RunDir string // "" = DefaultRunDir
@@ -186,6 +187,11 @@ func DropInPaths(etcDir, host string) (service, socket string) {
// the minute running the right unit text, so it is accepted here too.
func Converge(o Ops, p Plan) (Result, error) {
var res Result
+ if o.CheckHost != nil {
+ if err := o.CheckHost(p.Host); err != nil {
+ return res, err
+ }
+ }
res.Prev.Host = p.Host
// The host becomes a filename under /etc/systemd/system, written as
@@ -921,6 +927,11 @@ func CheckClaims(plans []Plan) map[string]error {
// not ours when the snapshot was taken is not ours to write over or delete
// now either.
func Restore(o Ops, prev Snapshot) error {
+ if o.CheckHost != nil {
+ if err := o.CheckHost(prev.Host); err != nil {
+ return err
+ }
+ }
if err := validHost(prev.Host); err != nil {
o.logf(" %q: %v", prev.Host, err)
return err
@@ -951,6 +962,11 @@ func Restore(o Ops, prev Snapshot) error {
// not this function's business: rootRestartRoute's try-restart keeps owning them,
// byte-identically.
func Restart(o Ops, p Plan) error {
+ if o.CheckHost != nil {
+ if err := o.CheckHost(p.Host); err != nil {
+ return err
+ }
+ }
if err := validHost(p.Host); err != nil {
o.logf(" %q: %v", p.Host, err)
return err
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 257204721..764dad1a5 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -4297,14 +4297,14 @@ func runAdoptWithEnsureSystemUnits(args []string, buildClaims ClaimFactory, ensu
probe = sources[0].Census
}
err := withClaimRootRelease(context.Background(), hostwrite.Enabled(os.Getenv), open, func() error {
- return runAdoptUnderRootRelease(args, buildClaims, ensureUnits)
+ return runAdoptUnderRootRelease(args, buildClaims, ensureUnits, probe)
}, probe)
if err != nil {
log.Fatalf("adopt: %v", err)
}
}
-func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUnits func()) error {
+func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUnits func(), census func() (rootparticipation.Ledger, bool, error)) error {
reapStagingWrites(release.OptDir(), dataDir(), "/etc", log.Printf)
if os.Geteuid() == 0 {
if err := repairOffcloudCredentials("/etc/carlos/credentials"); err != nil {
@@ -4396,6 +4396,16 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni
// stage-requested). Here, before the fatal gates below, so a pass that
// dies later still judged. Never fatal; one stat on a box with no floor.
restartChecks := productionRootRestartChecks(systemdUnits{}, release.OptDir())
+ restartChecks.legacy = func() (bool, error) {
+ if census == nil {
+ return false, os.ErrPermission
+ }
+ ledger, required, err := census()
+ if ledger.State != nil {
+ ledger.State.Close()
+ }
+ return !required && err == nil, err
+ }
if err := recoverRootRestarts(claimCtx, systemdUnits{}, release.OptDir(), os.Getenv, restartChecks, claims.Writes); err != nil {
log.Printf("adopt: %v", err)
}
@@ -4538,7 +4548,7 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni
//
// Never fatal. A failure here is the registry not listing; adoption of
// releases is a separate job and must still run.
- if err := capabilityPass(reg, release.OptDir(), caps.Ops{}); err != nil {
+ if err := capabilityPass(reg, release.OptDir(), productionClaimCapsOps(caps.Ops{}, restartChecks.allowLegacy)); err != nil {
log.Printf("adopt: capability pass: %v", err)
}
@@ -4585,7 +4595,7 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni
linkRootHistory(history, restartChecks, release.OptDir(), os.Getenv)
}
recovery := productionNamedUnitRecovery(store, pub, release.OptDir(), claims.Writes)
- passOpts := []release.PassOption{release.WithRootGenerationPins(generationPins), release.WithRootHistory(history), recovery.passOption(reg, restartChecks, os.Getenv), release.WithCapabilities(caps.Ops{}), release.BakeDir(dataDir()), release.WithRestartAttemptSettled(restartChecks.finish),
+ passOpts := []release.PassOption{release.WithRootGenerationPins(generationPins), release.WithRootHistory(history), recovery.passOption(reg, restartChecks, os.Getenv), release.WithCapabilities(productionClaimCapsOps(caps.Ops{}, restartChecks.allowLegacy)), release.BakeDir(dataDir()), release.WithRestartAttemptSettled(restartChecks.finish),
release.WithRunningVersion(restartChecks.runningVersion),
release.WithSelectedRestarts(
rootSelectedRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv, settled, restartChecks, false),
diff --git a/internal/host/claimlaunch_authority.go b/internal/host/claimlaunch_authority.go
index dbd92a35d..45aa0e48c 100644
--- a/internal/host/claimlaunch_authority.go
+++ b/internal/host/claimlaunch_authority.go
@@ -139,7 +139,7 @@ func (h *ClaimLaunchRoot) authorize(ctx context.Context, peer StopPeer, q hostbo
// waitReady observes one start, never reserves or starts another attempt.
// Type=simple returns before namespace setup and the trampoline exec.
-func (h *ClaimLaunchRoot) waitReady(ctx context.Context, s instancectl.ClaimUnitStarted, want hostbox.LaunchFile) (ClaimLaunchObserved, error) {
+func (h *ClaimLaunchRoot) waitReady(ctx context.Context, host string, s instancectl.ClaimUnitStarted, want hostbox.LaunchFile) (ClaimLaunchObserved, error) {
budget, poll := h.ReadyBudget, h.ReadyPoll
if budget <= 0 {
budget = 5 * time.Second
@@ -176,7 +176,7 @@ func (h *ClaimLaunchRoot) waitReady(ctx context.Context, s instancectl.ClaimUnit
if err := h.Units.Same(ctx, s); err != nil {
return ClaimLaunchObserved{}, err
}
- if h.boundReady == nil || h.boundReady(ctx, s) == nil {
+ if h.boundReady == nil || h.boundReady(ctx, host, s) == nil {
if err := h.Units.Same(ctx, s); err != nil {
return ClaimLaunchObserved{}, err
}
diff --git a/internal/host/claimlaunch_root.go b/internal/host/claimlaunch_root.go
index 85489af03..ff95c33df 100644
--- a/internal/host/claimlaunch_root.go
+++ b/internal/host/claimlaunch_root.go
@@ -62,7 +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
+ boundReady func(context.Context, string, instancectl.ClaimUnitStarted) error
unitProgram func(claimUnitShape) (claimUnitProgram, error)
unitBindings *claimUnitBindings
history rootHistoryAccess
@@ -248,7 +248,7 @@ func (h *ClaimLaunchRoot) startRecorded(ctx context.Context, peer StopPeer, q ho
grant, e = h.launchLocked(ctx, peer, q, r, pin)
if e == nil && history != nil {
started := instancectl.ClaimUnitStarted{Unit: grant.Unit, InvocationID: grant.UnitInvocationID, MainPID: grant.PID, ControlGroup: grant.Group.RelativePath}
- observed, err := h.waitReady(ctx, started, r.Executable)
+ observed, err := h.waitReady(ctx, r.Target.Host, started, r.Executable)
if err != nil {
return err
}
@@ -370,7 +370,7 @@ func (h *ClaimLaunchRoot) launchLocked(ctx context.Context, peer StopPeer, q hos
if err != nil {
return hostbox.RoleLaunchGrant{}, errors.Join(err, h.settleGone(cleanup, t, r.ID, ""))
}
- observed, err := h.waitReady(ctx, started, r.Executable)
+ observed, err := h.waitReady(ctx, r.Target.Host, started, r.Executable)
if err == nil && (started.ControlGroup != r.Group.RelativePath || observed.Executable != r.Executable || observed.ProcessStart == "" || observed.Group.Device == 0 || observed.Group.Inode == 0) {
err = hostbox.ErrChanged
}
diff --git a/internal/host/claimproduction.go b/internal/host/claimproduction.go
index 53f40ddbf..c9370e474 100644
--- a/internal/host/claimproduction.go
+++ b/internal/host/claimproduction.go
@@ -180,7 +180,11 @@ 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, NewClaimRoleRootClient(controlsock.Path).UnitInputs)
+ unitInputs := claimUnitInputReader(NewClaimRoleRootClient(controlsock.Path).UnitInputs)
+ if r.Mode != "agent" {
+ unitInputs = rootClaimUnitInputs
+ }
+ out.Serving.Launch = claimServingLaunchSource(backend, tokens, 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)}
@@ -213,7 +217,8 @@ func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store r
return out, err
}
bindClaimLegacyOwners(&out)
- out.InputPolicy = claimInputPolicy(r.Registry.Get, store, pub, backend)
+ out.InputPolicy = claimInputPolicy(r.Registry.Get, store, pub, backend, unitInputs)
+ out.PolicyRefusals = append(out.PolicyRefusals, productionClaimUnitShapePolicy(r.Mode, r.Registry.Get))
out.MovesPolicy = MovesClaimPolicy(MovesRecordsFrom(store))
bindProductionClaimInputPolicy(claimEntryEnv(ctx), &out, MovesRecordsFrom(store))
regional := productionClaimRegional{Identity: out.Identity, Parks: parks, Local: local}
@@ -257,7 +262,7 @@ func claimExecBackend(r ClaimRuntimeResources, keys instanceKeyReaders) *activat
// launched here, so the replica gate does not run, no bucket is read for a
// configured key, and the answer is hostbox.ErrChanged or nil, never a
// launch refusal (final review I4).
-func claimInputPolicy(route func(string) (*registry.Route, error), store release.Store, pub ed25519.PublicKey, backend *activator.ExecBackend) func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error {
+func claimInputPolicy(route func(string) (*registry.Route, error), store release.Store, pub ed25519.PublicKey, backend *activator.ExecBackend, readers ...claimUnitInputReader) func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error {
return func(ctx context.Context, c *hostclaim.Claim, s hostbox.Selection, id hostwrite.Identity) error {
if c == nil || c.Holder == nil || c.Op.Kind != hostclaim.KindServing || s.Host != c.Host || s.Captured.Incarnation != c.Holder.Incarnation || s.Captured.Stamp != c.Holder.Stamp {
return hostbox.ErrChanged
@@ -269,6 +274,32 @@ func claimInputPolicy(route func(string) (*registry.Route, error), store release
if rt == nil {
return hostbox.ErrChanged
}
+ var unit *hostbox.UnitLaunchInputs
+ if len(readers) == 1 {
+ unit, err = readers[0](ctx, *rt)
+ if err != nil {
+ return err
+ }
+ }
+ if unit != nil {
+ if id.Config != "" && id.Release == "" {
+ if id.Config != unit.Program {
+ return hostbox.ErrChanged
+ }
+ return nil
+ }
+ if id.Release != "" && id.Config == "" {
+ return release.CheckRouteReleaseIdentity(ctx, store, pub, *rt, id)
+ }
+ snapshot, err := claimBoundSnapshot(ctx, backend, *rt, func(context.Context, registry.Route) (*hostbox.UnitLaunchInputs, error) { return unit, nil })
+ if err != nil {
+ return err
+ }
+ if id != snapshot.Identity {
+ return hostbox.ErrChanged
+ }
+ return nil
+ }
if id.Config != "" && id.Release == "" {
var private *ecdh.PrivateKey
if raw := os.Getenv("CARLOS_SECRETS_KEY"); raw != "" {
@@ -282,16 +313,6 @@ 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
diff --git a/internal/host/claimunit_binding.go b/internal/host/claimunit_binding.go
index 4910a370d..cef141054 100644
--- a/internal/host/claimunit_binding.go
+++ b/internal/host/claimunit_binding.go
@@ -22,6 +22,7 @@ import (
type claimUnitShape struct {
Host, Account, App, Kind, Unit, Backing, Socket, Addr, DBPath string
Program string `json:",omitempty"`
+ PredecessorDigest string `json:",omitempty"`
}
type claimUnitBinding struct {
@@ -39,7 +40,7 @@ type claimUnitBindings struct {
}
func (s claimUnitShape) valid() bool {
- if s.Program != "" && !claimHexDigest(s.Program) {
+ if (s.Program != "" && !claimHexDigest(s.Program)) || (s.PredecessorDigest != "" && !claimHexDigest(s.PredecessorDigest)) {
return false
}
if !provision.ValidHostShape(s.Host) || !release.ValidAccount(s.Account) || !release.ValidApp(s.App) || s.Kind != "instance" || !instancectl.ValidBoundClaimUnit(s.Unit) {
@@ -138,7 +139,7 @@ func (s *claimUnitBindings) current(ctx context.Context, host string) (claimUnit
return b, nil
}
-func (s *claimUnitBindings) establish(ctx context.Context, host string) (claimUnitBinding, error) {
+func (s *claimUnitBindings) establish(ctx context.Context, host string, resolved ...claimUnitBinding) (claimUnitBinding, error) {
if s == nil || s.state == nil {
return claimUnitBinding{}, os.ErrPermission
}
@@ -147,10 +148,18 @@ func (s *claimUnitBindings) establish(ctx context.Context, host string) (claimUn
return claimUnitBinding{}, err
}
defer unlock()
- next, err := s.current(ctx, host)
+ var next claimUnitBinding
+ if len(resolved) == 1 {
+ next = resolved[0]
+ } else {
+ next, err = s.current(ctx, host)
+ }
if err != nil {
return next, err
}
+ if !next.valid() || next.Owner.Host != host {
+ return next, os.ErrPermission
+ }
names, err := s.state.Names()
if err != nil {
return claimUnitBinding{}, err
@@ -165,7 +174,7 @@ func (s *claimUnitBindings) establish(ctx context.Context, host string) (claimUn
}
other, err := s.read(otherHost)
if err != nil {
- return claimUnitBinding{}, err
+ continue
}
if other.Shape.Unit == next.Shape.Unit {
return claimUnitBinding{}, os.ErrPermission
diff --git a/internal/host/claimunit_definition.go b/internal/host/claimunit_definition.go
index 6aa089861..7d7223ed6 100644
--- a/internal/host/claimunit_definition.go
+++ b/internal/host/claimunit_definition.go
@@ -1,15 +1,12 @@
package host
import (
- "bytes"
- "io"
"os"
"path/filepath"
"strings"
"github.com/carlosframework/platform/internal/instancectl"
"github.com/carlosframework/platform/internal/provision"
- "github.com/carlosframework/platform/internal/rootstate"
)
func claimBoundUnitDefinition(s claimUnitShape) ([]byte, error) {
@@ -37,26 +34,6 @@ func claimBoundUnitDefinition(s claimUnitShape) ([]byte, error) {
return []byte(body), nil
}
-func verifyClaimBoundUnitDefinition(s claimUnitShape) error {
- want, err := claimBoundUnitDefinition(s)
- if err != nil {
- return err
- }
- f, err := rootstate.OpenTrustedFile(filepath.Join("/run/systemd/system", s.Unit))
- if err != nil {
- return err
- }
- defer f.Close()
- got, err := io.ReadAll(io.LimitReader(f, rootstate.MaxRecord+1))
- if err != nil {
- return err
- }
- if !bytes.Equal(got, want) {
- return os.ErrPermission
- }
- return nil
-}
-
func claimFlatSocket(s claimUnitShape) bool {
if filepath.Dir(s.Socket) != provision.SocketDir || !strings.HasSuffix(s.Socket, ".sock") {
return false
diff --git a/internal/host/claimunit_dispatch.go b/internal/host/claimunit_dispatch.go
index 133940340..bd9fa3095 100644
--- a/internal/host/claimunit_dispatch.go
+++ b/internal/host/claimunit_dispatch.go
@@ -15,6 +15,7 @@ type claimUnitDispatch struct {
bindings *claimUnitBindings
shapes func() (map[string]claimUnitShape, error)
definition func(claimUnitShape) error
+ effective func(context.Context, claimUnitShape) error
}
func (d claimUnitDispatch) selected(host string) (instancectl.ClaimUnits, error) {
@@ -30,13 +31,7 @@ func (d claimUnitDispatch) selected(host string) (instancectl.ClaimUnits, error)
if !errors.Is(err, os.ErrNotExist) {
return u, err
}
- shapes, err := d.shapes()
- if err != nil {
- return u, err
- }
- if _, configured := shapes[host]; configured {
- return u, os.ErrPermission
- }
+
return u, nil
}
@@ -45,6 +40,14 @@ func (d claimUnitDispatch) forStart(ctx context.Context, host string) (instancec
if err != nil {
return u, err
}
+ if u.BoundUnit == "" {
+ shapes, e := d.shapes()
+ if e == nil {
+ if _, configured := shapes[host]; configured {
+ return u, os.ErrPermission
+ }
+ }
+ }
if u.BoundUnit != "" {
shapes, err := d.shapes()
if err != nil {
@@ -63,6 +66,11 @@ func (d claimUnitDispatch) forStart(ctx context.Context, host string) (instancec
if err := d.definition(b.Shape); err != nil {
return u, err
}
+ if d.effective != nil {
+ if err := d.effective(ctx, b.Shape); err != nil {
+ return u, err
+ }
+ }
}
return u, nil
}
diff --git a/internal/host/claimunit_dispatch_test.go b/internal/host/claimunit_dispatch_test.go
index 6daa8ace6..a9ba92cd6 100644
--- a/internal/host/claimunit_dispatch_test.go
+++ b/internal/host/claimunit_dispatch_test.go
@@ -13,7 +13,7 @@ func TestPlan14UnitDispatch(t *testing.T) {
b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "")
configured := map[string]claimUnitShape{in.Host: shape}
d := claimUnitDispatch{bindings: b, shapes: func() (map[string]claimUnitShape, error) { return configured, nil }}
- if _, _, err := d.Placement(in.Host); err == nil {
+ if _, err := d.forStart(t.Context(), in.Host); err == nil {
t.Fatal("configured unit without retained binding fell through")
}
if _, err := b.establish(t.Context(), in.Host); err != nil {
@@ -47,8 +47,8 @@ func TestPlan14UnitDispatch(t *testing.T) {
t.Fatal("corruption fell through")
}
d.shapes = func() (map[string]claimUnitShape, error) { return nil, os.ErrPermission }
- if _, _, err := d.Placement("sibling.example"); err == nil {
- t.Fatal("unreadable configuration inferred absence")
+ if _, _, err := d.Placement("sibling.example"); err != nil {
+ t.Fatal("unreadable named configuration blocked ordinary cleanup", err)
}
}
diff --git a/internal/host/claimunit_dropin_linux.go b/internal/host/claimunit_dropin_linux.go
new file mode 100644
index 000000000..30e0265c7
--- /dev/null
+++ b/internal/host/claimunit_dropin_linux.go
@@ -0,0 +1,51 @@
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/rootstate"
+ "github.com/carlosframework/platform/internal/safefile"
+ "golang.org/x/sys/unix"
+ "os"
+)
+
+func openClaimUnitDropin(unit string) (*os.File, error) {
+ if !instancectl.ValidBoundClaimUnit(unit) {
+ return nil, os.ErrPermission
+ }
+ const base = "/run/systemd/system"
+ checked, err := rootstate.Open(base, false, true)
+ if err != nil {
+ return nil, err
+ }
+ defer checked.Close()
+ parent, err := safefile.OpenDir(base)
+ if err != nil {
+ return nil, err
+ }
+ defer parent.Close()
+ fd, err := unix.Openat(int(parent.Fd()), unit+".d", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
+ if err != nil {
+ return nil, err
+ }
+ defer unix.Close(fd)
+ var st unix.Stat_t
+ if err := unix.Fstat(fd, &st); err != nil {
+ return nil, err
+ }
+ if st.Uid != 0 || st.Mode&0022 != 0 || st.Mode&unix.S_IFMT != unix.S_IFDIR {
+ return nil, os.ErrPermission
+ }
+ f, err := safefile.OpenFileAt(fd, "60-claim-launch.conf", os.O_RDONLY, 0)
+ if err != nil {
+ return nil, err
+ }
+ if err = unix.Fstat(int(f.Fd()), &st); err != nil {
+ f.Close()
+ return nil, err
+ }
+ if st.Uid != 0 || st.Mode&0022 != 0 || st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 {
+ f.Close()
+ return nil, os.ErrPermission
+ }
+ return f, nil
+}
diff --git a/internal/host/claimunit_dropin_other.go b/internal/host/claimunit_dropin_other.go
new file mode 100644
index 000000000..da7b4bce7
--- /dev/null
+++ b/internal/host/claimunit_dropin_other.go
@@ -0,0 +1,7 @@
+//go:build !linux
+
+package host
+
+import "os"
+
+func openClaimUnitDropin(string) (*os.File, error) { return nil, os.ErrPermission }
diff --git a/internal/host/claimunit_effective.go b/internal/host/claimunit_effective.go
new file mode 100644
index 000000000..3163cbd16
--- /dev/null
+++ b/internal/host/claimunit_effective.go
@@ -0,0 +1,147 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/netip"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func claimReservedDropin(s claimUnitShape) []byte {
+ source := filepath.Join("/run/carlos-control/exe/.claim", provision.InstanceID(s.Host))
+ return []byte("[Service]\nBindReadOnlyPaths=" + source + "/launch.json:" + filepath.Join(provision.InstanceDir("/data", s.Host), instancectl.LaunchFileName(instancectl.RoleRun)) + "\nBindReadOnlyPaths=" + source + "/exec.sock:" + instancectl.ClaimExecSocket + "\nBindReadOnlyPaths=" + source + "/permit.json:" + instancectl.ClaimExecSnapshot + "\nBindReadOnlyPaths=/proc/sys/kernel/random/boot_id:" + instancectl.ClaimExecBootID + "\nExecStart=\nExecStart=" + instancectl.SystemBinary + " instance-exec -claim-reservation=" + instancectl.ClaimExecSnapshot + " run " + provision.InstanceID(s.Host) + "\n")
+}
+
+type claimEffectiveRefusal struct {
+ field string
+ cause error
+}
+
+func (e claimEffectiveRefusal) Error() string { return e.cause.Error() }
+func (e claimEffectiveRefusal) Unwrap() error { return e.cause }
+
+func claimEffectiveUnit(ctx context.Context, s claimUnitShape, ctl func(context.Context, ...string) (string, error)) error {
+ if ctl == nil {
+ return claimEffectiveRefusal{"unknown", os.ErrPermission}
+ }
+ scalar := map[string]string{"User": "carlos", "Group": "carlos", "NoNewPrivileges": "yes", "ProtectSystem": "strict", "ProtectHome": "yes", "PrivateTmp": "yes", "PrivateDevices": "yes", "PrivateIPC": "yes", "ProtectControlGroups": "yes", "ProtectProc": "invisible", "ProcSubset": "pid", "PrivatePIDs": "yes", "RestrictNamespaces": "yes", "CapabilityBoundingSet": "", "MemoryDenyWriteExecute": "yes", "ProtectKernelTunables": "yes", "ProtectKernelModules": "yes", "ProtectKernelLogs": "yes", "ProtectClock": "yes", "ProtectHostname": "yes", "RemoveIPC": "yes", "LockPersonality": "yes", "RestrictRealtime": "yes", "Restart": "no", "Slice": instancectl.SliceName(s.Host)}
+ keys := []string{"FragmentPath", "DropInPaths", "NeedDaemonReload", "ExecStart", "IPAddressAllow", "IPAddressDeny"}
+ for key := range scalar {
+ keys = append(keys, key)
+ }
+ slices.Sort(keys)
+ raw, err := ctl(ctx, "show", "-p", strings.Join(keys, ","), s.Unit, "--all")
+ if err != nil {
+ return err
+ }
+ props := map[string]string{}
+ for _, line := range strings.Split(raw, "\n") {
+ if key, value, ok := strings.Cut(line, "="); ok {
+ if _, seen := props[key]; seen {
+ return claimEffectiveRefusal{"unknown", os.ErrPermission}
+ }
+ props[key] = value
+ }
+ }
+ for _, key := range keys {
+ if _, ok := props[key]; !ok {
+ return claimEffectiveRefusal{"missing:" + key, os.ErrPermission}
+ }
+ }
+ if props["FragmentPath"] != "/etc/systemd/system/"+s.Unit || props["NeedDaemonReload"] != "no" {
+ return claimEffectiveRefusal{"unknown", os.ErrPermission}
+ }
+ for key, want := range scalar {
+ if props[key] != want {
+ return claimEffectiveRefusal{"value:" + key + "=" + props[key], os.ErrPermission}
+ }
+ }
+ executable := "/usr/bin/false"
+ if props["DropInPaths"] != "" {
+ path := filepath.Join("/run/systemd/system", s.Unit+".d", "60-claim-launch.conf")
+ if props["DropInPaths"] != path {
+ return claimEffectiveRefusal{"dropins", os.ErrPermission}
+ }
+ f, err := openClaimUnitDropin(s.Unit)
+ if err != nil {
+ return claimEffectiveRefusal{"dropin-file", err}
+ }
+ body, err := io.ReadAll(io.LimitReader(f, rootstate.MaxRecord+1))
+ f.Close()
+ if err != nil {
+ return err
+ }
+ if !bytes.Equal(body, claimReservedDropin(s)) {
+ return claimEffectiveRefusal{"dropin-bytes", os.ErrPermission}
+ }
+ executable = instancectl.SystemBinary
+ }
+ if strings.Count(props["ExecStart"], "path=") != 1 || !strings.Contains(props["ExecStart"], "path="+executable+" ;") {
+ return claimEffectiveRefusal{"unknown", os.ErrPermission}
+ }
+ definition, err := claimBoundUnitDefinition(s)
+ if err != nil {
+ return err
+ }
+ for _, key := range []string{"IPAddressAllow", "IPAddressDeny"} {
+ want := ""
+ for _, line := range strings.Split(string(definition), "\n") {
+ if strings.HasPrefix(line, key+"=") {
+ want += " " + strings.TrimPrefix(line, key+"=")
+ }
+ }
+ a, e := claimIPSet(want)
+ if e != nil {
+ return e
+ }
+ b, e := claimIPSet(props[key])
+ if e != nil {
+ return e
+ }
+ if !slices.Equal(a, b) {
+ return claimEffectiveRefusal{"network:" + key, os.ErrPermission}
+ }
+ }
+ return nil
+}
+
+func claimIPSet(raw string) ([]string, error) {
+ var out []string
+ raw = strings.ReplaceAll(raw, "localhost", "127.0.0.0/8 ::1/128")
+ raw = strings.ReplaceAll(raw, "multicast", "224.0.0.0/4 ff00::/8")
+ for _, value := range strings.Fields(raw) {
+ p, e := netip.ParsePrefix(value)
+ if e != nil {
+ return nil, os.ErrPermission
+ }
+ out = append(out, p.Masked().String())
+ }
+ slices.Sort(out)
+ out = slices.Compact(out)
+ // systemd removes redundant subnets when attaching the running unit's
+ // filter. Compare address sets, including before and after that reduction.
+ kept := make([]string, 0, len(out))
+ for _, value := range out {
+ p := netip.MustParsePrefix(value)
+ covered := false
+ for _, other := range out {
+ q := netip.MustParsePrefix(other)
+ if q.Bits() < p.Bits() && q.Contains(p.Addr()) {
+ covered = true
+ break
+ }
+ }
+ if !covered {
+ kept = append(kept, value)
+ }
+ }
+ return kept, nil
+}
diff --git a/internal/host/claimunit_effective_test.go b/internal/host/claimunit_effective_test.go
new file mode 100644
index 000000000..6b7664119
--- /dev/null
+++ b/internal/host/claimunit_effective_test.go
@@ -0,0 +1,67 @@
+package host
+
+import (
+ "context"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+)
+
+func TestPlan14EffectiveBoundUnit(t *testing.T) {
+ _, _, shape := plan14UnitBindingFixture(t, "console.service", "", "")
+ shape.Program = strings.Repeat("a", 64)
+ definition, err := claimBoundUnitDefinition(shape)
+ if err != nil {
+ t.Fatal(err)
+ }
+ values := map[string]string{"FragmentPath": "/etc/systemd/system/console.service", "DropInPaths": "", "NeedDaemonReload": "no", "ExecStart": "{ path=/usr/bin/false ; argv[]=/usr/bin/false ; }", "IPAddressAllow": "", "IPAddressDeny": "", "Slice": instancectl.SliceName(shape.Host)}
+ for _, line := range strings.Split(string(definition), "\n") {
+ k, v, ok := strings.Cut(line, "=")
+ if !ok {
+ continue
+ }
+ if k == "ExecStart" {
+ continue
+ }
+ values[k] = v
+ }
+ for _, attack := range []string{"none", "DropInPaths", "User", "Group", "NoNewPrivileges", "ProtectSystem", "PrivatePIDs", "IPAddressAllow", "ExecStart", "NeedDaemonReload", "FragmentPath"} {
+ t.Run(attack, func(t *testing.T) {
+ ctl := func(_ context.Context, args ...string) (string, error) {
+ var out strings.Builder
+ for _, key := range strings.Split(args[2], ",") {
+ value := values[key]
+ if key == attack {
+ value = "forged"
+ }
+ out.WriteString(key + "=" + value + "\n")
+ }
+ return out.String(), nil
+ }
+ err := claimEffectiveUnit(t.Context(), shape, ctl)
+ if attack == "none" && err != nil {
+ t.Fatal(err)
+ }
+ if attack != "none" && err == nil {
+ t.Fatal("effective override accepted", attack)
+ }
+ })
+ }
+}
+
+func TestPlan14EffectiveNetworkReduction(t *testing.T) {
+ a, err := claimIPSet("localhost 169.254.0.0/16 169.254.169.254/32 fc00::/7 fd00:ec2::254/128")
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := claimIPSet("127.0.0.0/8 ::1/128 169.254.0.0/16 fc00::/7")
+ if err != nil || !slices.Equal(a, b) {
+ t.Fatal(a, b, err)
+ }
+ c, err := claimIPSet("127.0.0.0/8 ::1/128 169.254.0.0/15 fc00::/7")
+ if err != nil || slices.Equal(a, c) {
+ t.Fatal("broadened set accepted", a, c, err)
+ }
+}
diff --git a/internal/host/claimunit_inputs.go b/internal/host/claimunit_inputs.go
index 5c1c5d8ed..1f2c6fcac 100644
--- a/internal/host/claimunit_inputs.go
+++ b/internal/host/claimunit_inputs.go
@@ -25,7 +25,7 @@ func (h *ClaimLaunchRoot) boundInputs(ctx context.Context, rt registry.Route) (*
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) {
+ if _, e := h.unitBindings.canonical(ctx, rt.Host); e == nil {
return os.ErrPermission
}
if rt.Unit != "" || rt.Backing != "" || rt.Tunnel != "" {
diff --git a/internal/host/claimunit_materialize.go b/internal/host/claimunit_materialize.go
index ab623d23d..84b7568ef 100644
--- a/internal/host/claimunit_materialize.go
+++ b/internal/host/claimunit_materialize.go
@@ -14,6 +14,7 @@ import (
)
type claimUnitMaterialization struct {
+ Migration *claimUnitMigration
Prior claimUnitBinding
PriorIdentity rootstate.FileIdentity
Binding claimUnitBinding
@@ -23,11 +24,13 @@ type claimUnitMaterialization struct {
}
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
+ predecessor func(context.Context, claimUnitBinding) (claimUnitPredecessor, error)
+ stopPredecessor func(context.Context, claimUnitPredecessor) error
+ 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[:]) }
@@ -43,6 +46,9 @@ func (m claimUnitMaterializer) load(host string) (claimUnitMaterialization, erro
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.Migration != nil && (!r.Migration.valid(r.Binding) || r.Prior != (claimUnitBinding{})) {
+ 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
}
@@ -83,20 +89,24 @@ func (m claimUnitMaterializer) install(ctx context.Context, b claimUnitBinding)
}
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
+ raw, id, e := m.read(b.Shape.Unit)
+ r = claimUnitMaterialization{Binding: b, Digest: claimUnitDigest(want)}
+ if e == nil {
+ r.Migration, err = m.migration(ctx, b, raw, id)
+ if err != nil {
+ return err
}
- return os.ErrPermission
+ } else if !errors.Is(e, os.ErrNotExist) {
+ return e
}
- 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 r.Identity == (rootstate.FileIdentity{}) && r.Migration == nil {
if err := m.idle(b.Shape.Host); err != nil {
return err
}
@@ -104,6 +114,9 @@ func (m claimUnitMaterializer) install(ctx context.Context, b claimUnitBinding)
if err = m.save(r); err != nil {
return err
}
+ if r.Identity == (rootstate.FileIdentity{}) && r.Migration != nil {
+ return m.finishMigration(ctx, r, want)
+ }
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 {
diff --git a/internal/host/claimunit_migrate.go b/internal/host/claimunit_migrate.go
new file mode 100644
index 000000000..08f63e357
--- /dev/null
+++ b/internal/host/claimunit_migrate.go
@@ -0,0 +1,99 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+type claimUnitPredecessor struct {
+ Unit, Invocation, Group string
+ GroupIdentity rootstate.FileIdentity
+}
+
+type claimUnitMigration struct {
+ Definition rootstate.FileIdentity
+ Digest string
+ Predecessor claimUnitPredecessor
+}
+
+func (p claimUnitPredecessor) valid(unit string) bool {
+ return p.Unit == unit && instancectl.ValidBoundClaimUnit(unit) && p.Invocation != "" && claimPredecessorPlacement(unit, p.Group) && p.GroupIdentity != (rootstate.FileIdentity{})
+}
+
+func (r claimUnitMigration) valid(b claimUnitBinding) bool {
+ return r.Definition != (rootstate.FileIdentity{}) && claimHexDigest(r.Digest) && r.Digest == b.Shape.PredecessorDigest && r.Predecessor.valid(b.Shape.Unit)
+}
+
+// The materialization lock and root host lock cover capture through replacement.
+// Only the root configuration's exact digest admits an operator definition.
+func (m claimUnitMaterializer) migration(ctx context.Context, b claimUnitBinding, raw []byte, id rootstate.FileIdentity) (*claimUnitMigration, error) {
+ if !claimHexDigest(b.Shape.PredecessorDigest) || claimUnitDigest(raw) != b.Shape.PredecessorDigest || id == (rootstate.FileIdentity{}) || m.predecessor == nil || m.stopPredecessor == nil || m.replace == nil {
+ return nil, os.ErrPermission
+ }
+ p, err := m.predecessor(ctx, b)
+ if err != nil {
+ return nil, err
+ }
+ r := &claimUnitMigration{Definition: id, Digest: claimUnitDigest(raw), Predecessor: p}
+ if !r.valid(b) {
+ return nil, os.ErrPermission
+ }
+ return r, nil
+}
+
+func (m claimUnitMaterializer) finishMigration(ctx context.Context, r claimUnitMaterialization, want []byte) error {
+ if r.Migration == nil || !r.Migration.valid(r.Binding) || m.stopPredecessor == nil || m.replace == nil {
+ return os.ErrPermission
+ }
+ raw, id, err := m.read(r.Binding.Shape.Unit)
+ if err != nil {
+ return err
+ }
+ if id == r.Migration.Definition && claimUnitDigest(raw) == r.Migration.Digest {
+ if err = m.stopPredecessor(ctx, r.Migration.Predecessor); err != nil {
+ return err
+ }
+ if err = m.idle(r.Binding.Shape.Host); err != nil {
+ return err
+ }
+ if err = m.replace(r.Binding.Shape.Unit, id, want); err != nil {
+ return err
+ }
+ raw, id, err = m.read(r.Binding.Shape.Unit)
+ if err != nil {
+ return err
+ }
+ } else if !bytes.Equal(raw, want) {
+ return os.ErrPermission
+ }
+ if !bytes.Equal(raw, want) || id == (rootstate.FileIdentity{}) {
+ return os.ErrPermission
+ }
+ // A crash after rename still has to prove the predecessor gone. The durable
+ // definition refuses execution until a later root reservation opens its gate.
+ if err = m.stopPredecessor(ctx, r.Migration.Predecessor); err != nil {
+ return err
+ }
+ if err = m.idle(r.Binding.Shape.Host); err != nil {
+ return err
+ }
+ r.Identity = id
+ return m.save(r)
+}
+
+func claimPredecessorPlacement(unit, group string) bool {
+ if !instancectl.ValidBoundClaimUnit(unit) {
+ return false
+ }
+ if group == "system.slice/"+unit {
+ return true
+ }
+ prefix, _, template := strings.Cut(unit, "@")
+ return template && prefix != "" && group == path.Join("system.slice", "system-"+instancectl.Escape(prefix)+".slice", unit)
+}
diff --git a/internal/host/claimunit_migrate_kernel.go b/internal/host/claimunit_migrate_kernel.go
new file mode 100644
index 000000000..ab50043f9
--- /dev/null
+++ b/internal/host/claimunit_migrate_kernel.go
@@ -0,0 +1,114 @@
+package host
+
+import (
+ "context"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+type claimPredecessorKernel struct {
+ ctl func(context.Context, ...string) (string, error)
+ group func(string) (rootstate.FileIdentity, bool, error)
+}
+
+func (k claimPredecessorKernel) show(ctx context.Context, unit string) (map[string]string, error) {
+ if k.ctl == nil || k.group == nil || !instancectl.ValidBoundClaimUnit(unit) {
+ return nil, os.ErrPermission
+ }
+ out, err := k.ctl(ctx, "show", "-p", "ActiveState,InvocationID,ControlGroup,FragmentPath,DropInPaths", unit)
+ if err != nil {
+ return nil, err
+ }
+ props := map[string]string{}
+ for _, line := range strings.Split(out, "\n") {
+ if key, value, ok := strings.Cut(line, "="); ok {
+ if _, exists := props[key]; exists {
+ return nil, os.ErrPermission
+ }
+ props[key] = value
+ }
+ }
+ for _, key := range []string{"ActiveState", "InvocationID", "ControlGroup", "FragmentPath", "DropInPaths"} {
+ if _, ok := props[key]; !ok {
+ return nil, os.ErrPermission
+ }
+ }
+ return props, nil
+}
+
+func (k claimPredecessorKernel) capture(ctx context.Context, b claimUnitBinding) (claimUnitPredecessor, error) {
+ var p claimUnitPredecessor
+ props, err := k.show(ctx, b.Shape.Unit)
+ if err != nil {
+ return p, err
+ }
+ group := strings.TrimPrefix(props["ControlGroup"], "/")
+ if props["ActiveState"] != "active" || props["InvocationID"] == "" || !claimPredecessorPlacement(b.Shape.Unit, group) || props["ControlGroup"] != "/"+group || props["FragmentPath"] != "/etc/systemd/system/"+b.Shape.Unit || props["DropInPaths"] != "" {
+ return p, os.ErrPermission
+ }
+ id, populated, err := k.group(group)
+ if err != nil {
+ return p, err
+ }
+ if !populated || id == (rootstate.FileIdentity{}) {
+ return p, os.ErrPermission
+ }
+ again, err := k.show(ctx, b.Shape.Unit)
+ if err != nil {
+ return p, err
+ }
+ for key, value := range props {
+ if again[key] != value {
+ return p, os.ErrPermission
+ }
+ }
+ return claimUnitPredecessor{Unit: b.Shape.Unit, Invocation: props["InvocationID"], Group: group, GroupIdentity: id}, nil
+}
+
+func (k claimPredecessorKernel) stop(ctx context.Context, p claimUnitPredecessor) error {
+ if !p.valid(p.Unit) {
+ return os.ErrPermission
+ }
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+ issued := false
+ for {
+ props, err := k.show(ctx, p.Unit)
+ if err != nil {
+ return err
+ }
+ if props["InvocationID"] != "" && props["InvocationID"] != p.Invocation {
+ return os.ErrPermission
+ }
+ id, populated, err := k.group(p.Group)
+ if err != nil {
+ return err
+ }
+ if id != (rootstate.FileIdentity{}) && id != p.GroupIdentity {
+ return os.ErrPermission
+ }
+ idle := props["ActiveState"] == "inactive" || props["ActiveState"] == "failed"
+ if idle && !populated {
+ return nil
+ }
+ if props["InvocationID"] != p.Invocation || props["ControlGroup"] != "/"+p.Group || id != p.GroupIdentity {
+ return os.ErrPermission
+ }
+ if !issued {
+ if _, err = k.ctl(ctx, "stop", p.Unit); err != nil {
+ return err
+ }
+ issued = true
+ continue
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(25 * time.Millisecond):
+ }
+ }
+}
diff --git a/internal/host/claimunit_migrate_kernel_test.go b/internal/host/claimunit_migrate_kernel_test.go
new file mode 100644
index 000000000..6c9cba806
--- /dev/null
+++ b/internal/host/claimunit_migrate_kernel_test.go
@@ -0,0 +1,99 @@
+package host
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func TestPlan14OperatorUnitPredecessorStop(t *testing.T) {
+ for _, attack := range []string{"none", "invocation", "cgroup", "inode", "dropin", "fragment", "missing property", "child remains", "lost stop"} {
+ t.Run(attack, func(t *testing.T) {
+ b, in, _ := plan14UnitBindingFixture(t, "console.service", "", "")
+ binding, err := b.establish(t.Context(), in.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ invocation, group, state := "original", "/system.slice/console.service", "active"
+ id := rootstate.FileIdentity{Device: 1, Inode: 2}
+ populated, stops := true, 0
+ k := claimPredecessorKernel{group: func(string) (rootstate.FileIdentity, bool, error) { return id, populated, nil }}
+ k.ctl = func(_ context.Context, args ...string) (string, error) {
+ if args[0] == "stop" {
+ stops++
+ state = "inactive"
+ if attack != "child remains" {
+ populated = false
+ }
+ if attack == "lost stop" {
+ return "", os.ErrInvalid
+ }
+ return "", nil
+ }
+ out := fmt.Sprintf("ActiveState=%s\nInvocationID=%s\nControlGroup=%s\nFragmentPath=/etc/systemd/system/console.service\nDropInPaths=", state, invocation, group)
+ switch attack {
+ case "dropin":
+ out += "/etc/systemd/system/console.service.d/override.conf"
+ case "fragment":
+ out = strings.ReplaceAll(out, "/etc/systemd/system", "/run/systemd/system")
+ case "missing property":
+ out = strings.ReplaceAll(out, "\nDropInPaths=", "")
+ }
+ return out, nil
+ }
+ p, err := k.capture(t.Context(), binding)
+ if attack == "dropin" || attack == "fragment" || attack == "missing property" {
+ if err == nil || stops != 0 {
+ t.Fatal("bad definition accepted", err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ switch attack {
+ case "invocation":
+ invocation = "successor"
+ case "cgroup":
+ group = "/system.slice/sibling.service"
+ case "inode":
+ id.Inode++
+ }
+ ctx, cancel := context.WithCancel(t.Context())
+ if attack == "child remains" {
+ original := k.ctl
+ k.ctl = func(ctx context.Context, args ...string) (string, error) {
+ out, e := original(ctx, args...)
+ if args[0] == "stop" {
+ cancel()
+ }
+ return out, e
+ }
+ }
+ defer cancel()
+ err = k.stop(ctx, p)
+ if attack == "none" {
+ if err != nil || stops != 1 {
+ t.Fatal(err, stops)
+ }
+ if err = k.stop(t.Context(), p); err != nil || stops != 1 {
+ t.Fatal("stop replay", err, stops)
+ }
+ } else if err == nil {
+ t.Fatal("unsafe stop accepted")
+ }
+ if (attack == "invocation" || attack == "cgroup" || attack == "inode") && stops != 0 {
+ t.Fatal("stopped successor")
+ }
+ if attack == "lost stop" {
+ if err = k.stop(t.Context(), p); err != nil || stops != 1 {
+ t.Fatal("lost stop replay", err, stops)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/host/claimunit_migrate_linux.go b/internal/host/claimunit_migrate_linux.go
new file mode 100644
index 000000000..994bac35d
--- /dev/null
+++ b/internal/host/claimunit_migrate_linux.go
@@ -0,0 +1,61 @@
+package host
+
+import (
+ "errors"
+ "os"
+ "path"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/rootstate"
+ "github.com/carlosframework/platform/internal/safefile"
+ "golang.org/x/sys/unix"
+)
+
+func claimPredecessorGroup(group string) (rootstate.FileIdentity, bool, error) {
+ var zero rootstate.FileIdentity
+ if !claimPredecessorPlacement(path.Base(group), group) {
+ return zero, false, os.ErrPermission
+ }
+ dir, err := safefile.OpenDir("/sys/fs/cgroup/" + group)
+ if errors.Is(err, os.ErrNotExist) {
+ return zero, false, nil
+ }
+ if err != nil {
+ return zero, false, err
+ }
+ defer dir.Close()
+ var st unix.Statfs_t
+ if err = unix.Fstatfs(int(dir.Fd()), &st); err != nil {
+ return zero, false, err
+ }
+ if st.Type != unix.CGROUP2_SUPER_MAGIC {
+ return zero, false, os.ErrPermission
+ }
+ id, err := rootstate.Identity(dir)
+ if err != nil {
+ return zero, false, err
+ }
+ fd, err := unix.Openat(int(dir.Fd()), "cgroup.events", unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
+ if err != nil {
+ return zero, false, err
+ }
+ file := os.NewFile(uintptr(fd), "cgroup.events")
+ defer file.Close()
+ var buf [1024]byte
+ n, err := file.Read(buf[:])
+ if err != nil {
+ return zero, false, err
+ }
+ if n == len(buf) {
+ return zero, false, os.ErrPermission
+ }
+ for _, line := range strings.Split(string(buf[:n]), "\n") {
+ if line == "populated 0" {
+ return id, false, nil
+ }
+ if line == "populated 1" {
+ return id, true, nil
+ }
+ }
+ return zero, false, os.ErrPermission
+}
diff --git a/internal/host/claimunit_migrate_other.go b/internal/host/claimunit_migrate_other.go
new file mode 100644
index 000000000..102a3eafa
--- /dev/null
+++ b/internal/host/claimunit_migrate_other.go
@@ -0,0 +1,12 @@
+//go:build !linux
+
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/rootstate"
+ "os"
+)
+
+func claimPredecessorGroup(string) (rootstate.FileIdentity, bool, error) {
+ return rootstate.FileIdentity{}, false, os.ErrPermission
+}
diff --git a/internal/host/claimunit_migrate_test.go b/internal/host/claimunit_migrate_test.go
new file mode 100644
index 000000000..ecda20539
--- /dev/null
+++ b/internal/host/claimunit_migrate_test.go
@@ -0,0 +1,126 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func TestPlan14OperatorUnitMigration(t *testing.T) {
+ for _, attack := range []string{"none", "no consent", "changed bytes", "replacement inode", "replacement invocation", "lost intent", "lost stop", "lost replace", "lost completion"} {
+ t.Run(attack, func(t *testing.T) {
+ b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "")
+ old := []byte("[Service]\nExecStart=/opt/carlos/live/shop.example\n")
+ shape.Program = strings.Repeat("a", 64)
+ shape.PredecessorDigest = claimUnitDigest(old)
+ if attack == "no consent" {
+ shape.PredecessorDigest = ""
+ }
+ 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{body: old, id: rootstate.FileIdentity{Device: 1, Inode: 2}}
+ state := &plan14UnitBindingFault{Store: &rollbackMemory{files: map[string][]byte{}}}
+ stopped, replaced := false, false
+ kernel := claimUnitPredecessor{Unit: shape.Unit, Invocation: "old", Group: "system.slice/console.service", GroupIdentity: rootstate.FileIdentity{Device: 3, Inode: 4}}
+ m := claimUnitMaterializer{state: state, read: files.read, create: files.create, replace: files.replace, idle: func(string) error {
+ if !stopped {
+ return os.ErrPermission
+ }
+ return nil
+ }}
+ m.predecessor = func(context.Context, claimUnitBinding) (claimUnitPredecessor, error) { return kernel, nil }
+ m.stopPredecessor = func(_ context.Context, got claimUnitPredecessor) error {
+ r, e := m.load(in.Host)
+ if e != nil || r.Migration == nil || r.Migration.Predecessor != got {
+ t.Fatal("stop without durable exact intention", e)
+ }
+ if got != kernel {
+ return os.ErrPermission
+ }
+ stopped = true
+ if attack == "lost stop" {
+ return os.ErrInvalid
+ }
+ return nil
+ }
+ m.replace = func(unit string, id rootstate.FileIdentity, body []byte) error {
+ if !stopped {
+ t.Fatal("replace before predecessor stop")
+ }
+ if err := files.replace(unit, id, body); err != nil {
+ return err
+ }
+ replaced = true
+ if attack == "lost completion" {
+ state.fail = true
+ }
+ if attack == "lost replace" {
+ return os.ErrInvalid
+ }
+ return nil
+ }
+ if attack == "changed bytes" {
+ files.body = append(files.body, 'x')
+ }
+ if attack == "lost intent" {
+ state.fail = true
+ }
+ if attack == "replacement inode" || attack == "replacement invocation" {
+ // Persist the initial intention, then lose the reply before any effect.
+ state.fail = true
+ if m.install(t.Context(), binding) == nil {
+ t.Fatal("lost intention accepted")
+ }
+ state.fail = false
+ if attack == "replacement inode" {
+ files.id.Inode++
+ } else {
+ kernel.Invocation = "new"
+ }
+ }
+ err = m.install(t.Context(), binding)
+ if attack == "none" {
+ if err != nil {
+ t.Fatal(err)
+ }
+ } else if err == nil {
+ t.Fatal("attack accepted")
+ }
+ if attack == "no consent" || attack == "changed bytes" || attack == "replacement inode" || attack == "replacement invocation" || attack == "lost intent" {
+ if stopped || replaced {
+ t.Fatal("unowned predecessor changed")
+ }
+ return
+ }
+ state.fail = false
+ m.stopPredecessor = func(_ context.Context, got claimUnitPredecessor) error {
+ if got != kernel {
+ return os.ErrPermission
+ }
+ stopped = true
+ return nil
+ }
+ m.replace = files.replace
+ if err = m.install(t.Context(), binding); err != nil {
+ t.Fatal("resume", err)
+ }
+ if files.retires != 1 {
+ t.Fatal("repeated replacement", files.retires)
+ }
+ if err = m.verify(binding); err != nil {
+ t.Fatal(err)
+ }
+ files.id.Inode++
+ if err = m.install(t.Context(), binding); !errors.Is(err, os.ErrPermission) {
+ t.Fatal("rebound completed identity", err)
+ }
+ })
+ }
+}
diff --git a/internal/host/claimunit_policy.go b/internal/host/claimunit_policy.go
new file mode 100644
index 000000000..9d76513a5
--- /dev/null
+++ b/internal/host/claimunit_policy.go
@@ -0,0 +1,194 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "os"
+
+ "github.com/carlosframework/platform/internal/caps"
+ "github.com/carlosframework/platform/internal/controlsock"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+var openClaimUnitBindingsRead = func() (rootstate.Store, error) {
+ return rootstate.Open("/var/lib/carlos-claims/unit-bindings", false, false)
+}
+
+type claimUnitShapeReader func(context.Context, registry.Route, hostbox.Selection) error
+
+func claimUnitShapePolicy(route func(string) (*registry.Route, error), read claimUnitShapeReader) hostbox.PolicyRefusal {
+ return func(ctx context.Context, _ *hostclaim.Claim, s hostbox.Selection, _ hostwrite.Identity) error {
+ if route == nil || read == nil {
+ return os.ErrPermission
+ }
+ rt, err := route(s.Host)
+ if err != nil {
+ return err
+ }
+ if rt == nil || rt.Host != s.Host {
+ return hostbox.ErrChanged
+ }
+ return read(ctx, *rt, s)
+ }
+}
+
+func checkClaimUnitShape(ctx context.Context, b *claimUnitBindings, shapes map[string]claimUnitShape, rt registry.Route, s hostbox.Selection) error {
+ saved, err := b.read(rt.Host)
+ if errors.Is(err, os.ErrNotExist) {
+ if _, configured := shapes[rt.Host]; configured || rt.Unit != "" || rt.Backing != "" || rt.Tunnel != "" {
+ return hostbox.ErrUnsupported
+ }
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if saved.Retiring || saved.Retired || saved.Shape != shapes[rt.Host] || !saved.Shape.matches(rt) {
+ return hostbox.ErrChanged
+ }
+ if s.Host != "" && (saved.Owner.Host != s.Host || saved.Owner.Box != s.Box || saved.Owner.Incarnation != s.Captured.Incarnation || saved.Owner.Stamp != s.Captured.Stamp) {
+ return hostbox.ErrChanged
+ }
+ return ctx.Err()
+}
+
+func withClaimUnitShape(ctx context.Context, rt registry.Route, s hostbox.Selection, fn func(claimUnitBinding) error) error {
+ state, err := openClaimUnitBindingsRead()
+ shapes, configErr := productionClaimUnitShapes()
+ if errors.Is(err, os.ErrNotExist) && rt.Unit == "" && rt.Backing == "" && rt.Tunnel == "" && shapes[rt.Host].Host == "" {
+ return fn(claimUnitBinding{})
+ }
+ if err != nil {
+ return err
+ }
+ defer state.Close()
+ b := &claimUnitBindings{state: state}
+ if configErr != nil {
+ _, e := b.read(rt.Host)
+ if !errors.Is(e, os.ErrNotExist) || rt.Unit != "" || rt.Backing != "" || rt.Tunnel != "" {
+ return configErr
+ }
+ shapes = map[string]claimUnitShape{}
+ }
+ if err = checkClaimUnitShape(ctx, b, shapes, rt, s); err != nil {
+ return err
+ }
+ saved, err := b.read(rt.Host)
+ if errors.Is(err, os.ErrNotExist) {
+ return fn(claimUnitBinding{})
+ }
+ if err != nil {
+ return err
+ }
+ return fn(saved)
+}
+
+func productionClaimUnitShapePolicy(mode string, route func(string) (*registry.Route, error)) hostbox.PolicyRefusal {
+ return claimUnitShapePolicy(route, func(ctx context.Context, rt registry.Route, s hostbox.Selection) error {
+ if mode == "agent" {
+ _, err := NewClaimRoleRootClient(controlsock.Path).UnitInputs(ctx, rt)
+ return err
+ }
+ if mode != "adopt" && mode != "instance-control" {
+ return os.ErrPermission
+ }
+ return withClaimUnitShape(ctx, rt, s, func(claimUnitBinding) error { return nil })
+ })
+}
+
+func rootClaimUnitInputs(ctx context.Context, rt registry.Route) (*hostbox.UnitLaunchInputs, error) {
+ var out *hostbox.UnitLaunchInputs
+ err := withClaimUnitShape(ctx, rt, hostbox.Selection{}, func(b claimUnitBinding) error {
+ if b == (claimUnitBinding{}) {
+ return nil
+ }
+ p, err := readClaimUnitProgram(b.Shape)
+ if err != nil {
+ return err
+ }
+ out = &hostbox.UnitLaunchInputs{Unit: b.Shape.Unit, Socket: b.Shape.Socket, Addr: b.Shape.Addr, DBPath: b.Shape.DBPath, Program: b.Shape.Program, Args: p.Args, Env: p.Env}
+ return nil
+ })
+ return out, err
+}
+
+func rootClaimRestartShape(ctx context.Context, host string, rt *registry.Route) error {
+ if rt == nil {
+ rt = &registry.Route{Host: host}
+ }
+ if rt.Host != host {
+ return os.ErrPermission
+ }
+ return withClaimUnitShape(ctx, *rt, hostbox.Selection{}, func(b claimUnitBinding) error {
+ if b != (claimUnitBinding{}) {
+ return hostbox.ErrUnsupported
+ }
+ return nil
+ })
+}
+
+func (c rootRestartChecks) checkUnitShape(ctx context.Context, host string, rt *registry.Route) error {
+ if c.unitShape == nil {
+ return nil
+ }
+ legacy, err := c.allowLegacy()
+ if err != nil {
+ return err
+ }
+ if legacy {
+ return nil
+ }
+ return c.unitShape(ctx, host, rt)
+}
+
+func productionClaimCapsOps(o caps.Ops, census func() (bool, error)) caps.Ops {
+ previous := o.CheckHost
+ o.CheckHost = func(host string) error {
+ if census == nil {
+ return os.ErrPermission
+ }
+ legacy, err := census()
+ if err != nil {
+ return err
+ }
+ if !legacy {
+ if err := claimBoundCapsRefusal(host); err != nil {
+ return err
+ }
+ }
+ if previous != nil {
+ return previous(host)
+ }
+ return nil
+ }
+ return o
+}
+
+func claimBoundCapsRefusal(host string) error {
+ shapes, e := productionClaimUnitShapes()
+ if e == nil {
+ if _, bound := shapes[host]; bound {
+ return hostbox.ErrUnsupported
+ }
+ }
+ state, e := openClaimUnitBindingsRead()
+ if e != nil && !errors.Is(e, os.ErrNotExist) {
+ return e
+ }
+ if e == nil {
+ b := &claimUnitBindings{state: state}
+ _, e = b.read(host)
+ state.Close()
+ if e == nil {
+ return hostbox.ErrUnsupported
+ }
+ if !errors.Is(e, os.ErrNotExist) {
+ return e
+ }
+ }
+ return nil
+}
diff --git a/internal/host/claimunit_policy_test.go b/internal/host/claimunit_policy_test.go
new file mode 100644
index 000000000..caa7a1f7d
--- /dev/null
+++ b/internal/host/claimunit_policy_test.go
@@ -0,0 +1,219 @@
+package host
+
+import (
+ "context"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/caps"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func TestPlan14UnitShapeAllOperationKinds(t *testing.T) {
+ for _, unit := range []string{"console.service", "woodstar-socket.service", "carlos-app@shop.example.service"} {
+ t.Run(unit, func(t *testing.T) {
+ b, in, shape := plan14UnitBindingFixture(t, unit, "", "")
+ if strings.HasPrefix(unit, "carlos-app@") {
+ shape.Backing = "unit"
+ }
+ if unit == "woodstar-socket.service" {
+ shape.Addr = ""
+ shape.Socket = "/run/carlos/woodstar.sock"
+ }
+ b.canonical = func(context.Context, string) (claimUnitShape, error) { return shape, nil }
+ if _, err := b.establish(t.Context(), in.Host); err != nil {
+ t.Fatal(err)
+ }
+ candidate := shape.route()
+ read := func(ctx context.Context, rt registry.Route, s hostbox.Selection) error {
+ return checkClaimUnitShape(ctx, b, map[string]claimUnitShape{in.Host: shape}, rt, s)
+ }
+ refuse := claimUnitShapePolicy(func(string) (*registry.Route, error) { return &candidate, nil }, read)
+ for _, kind := range []string{hostclaim.KindServing, hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindReverting, hostclaim.KindRestoring, hostclaim.KindRelocating} {
+ for _, action := range []string{"install", "restore", "spawn", "writer", "config", "release", "timers", "held-config", "held-release", "held-timers", "probe"} {
+ s := hostbox.Selection{Host: in.Host, Box: in.Box, Captured: hostguard.Captured{Incarnation: in.Incarnation, Stamp: in.Stamp}, Action: action, Kind: kind}
+ c := &hostclaim.Claim{Host: in.Host, Op: hostclaim.Op{Kind: kind}}
+ candidate = shape.route()
+ if err := refuse(t.Context(), c, s, hostwrite.Identity{}); err != nil {
+ t.Fatal(kind, action, err)
+ }
+ for _, mutate := range []func(*registry.Route){
+ func(r *registry.Route) { r.Unit = ""; r.Backing = "" }, func(r *registry.Route) { r.Unit = "sibling.service" }, func(r *registry.Route) { r.Backing = "exec" },
+ func(r *registry.Route) { r.Account = "other" }, func(r *registry.Route) { r.App = "other" }, func(r *registry.Route) { r.Kind = "sidecar" },
+ func(r *registry.Route) { r.DBPath = "/data/sibling.db" }, func(r *registry.Route) { r.Socket = "/run/carlos/sibling.sock" }, func(r *registry.Route) { r.Addr = "127.0.0.1:9999" },
+ func(r *registry.Route) { r.Tunnel = "other" }, func(r *registry.Route) { r.Replica = true }, func(r *registry.Route) { r.Seed = "seed" },
+ } {
+ candidate = shape.route()
+ mutate(&candidate)
+ if err := refuse(t.Context(), c, s, hostwrite.Identity{}); err == nil {
+ t.Fatal("shape edit reached operation", kind, action, candidate)
+ }
+ }
+ }
+ }
+ s := hostbox.Selection{Host: in.Host, Box: in.Box, Captured: hostguard.Captured{Incarnation: in.Incarnation, Stamp: in.Stamp + 1}}
+ candidate = shape.route()
+ if err := refuse(t.Context(), nil, s, hostwrite.Identity{}); err == nil {
+ t.Fatal("successor used predecessor binding")
+ }
+ sibling := registry.Route{Host: "sibling.example", Kind: "instance"}
+ if err := checkClaimUnitShape(t.Context(), b, map[string]claimUnitShape{in.Host: shape}, sibling, hostbox.Selection{Host: sibling.Host}); err != nil {
+ t.Fatal("ordinary sibling refused", err)
+ }
+ })
+ }
+}
+
+func TestPlan14BoundCapabilitiesRefuseEffects(t *testing.T) {
+ b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "")
+ if _, err := b.establish(t.Context(), in.Host); err != nil {
+ t.Fatal(err)
+ }
+ oldOpen, oldShapes := openClaimUnitBindingsRead, productionClaimUnitShapes
+ defer func() { openClaimUnitBindingsRead, productionClaimUnitShapes = oldOpen, oldShapes }()
+ openClaimUnitBindingsRead = func() (rootstate.Store, error) { return b.state, nil }
+ for _, config := range []string{"good", "removed", "corrupt"} {
+ t.Run(config, func(t *testing.T) {
+ productionClaimUnitShapes = func() (map[string]claimUnitShape, error) {
+ if config == "corrupt" {
+ return nil, os.ErrPermission
+ }
+ if config == "removed" {
+ return map[string]claimUnitShape{}, nil
+ }
+ return map[string]claimUnitShape{in.Host: shape}, nil
+ }
+ dir := t.TempDir()
+ ops := productionClaimCapsOps(caps.Ops{EtcDir: dir, Systemctl: func(...string) error { t.Fatal("bound capability reached systemctl"); return nil }}, func() (bool, error) { return false, nil })
+ if _, err := caps.Converge(ops, caps.Plan{Host: in.Host}); err == nil {
+ t.Fatal("converge accepted bound host")
+ }
+ if err := caps.Restore(ops, caps.Snapshot{Host: in.Host}); err == nil {
+ t.Fatal("restore accepted bound host")
+ }
+ if err := caps.Restart(ops, caps.Plan{Host: in.Host}); err == nil {
+ t.Fatal("restart accepted bound host")
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil || len(entries) != 0 {
+ t.Fatal("wrote capability dropin", entries, err)
+ }
+ if err := claimBoundCapsRefusal("ordinary.example"); err != nil {
+ t.Fatal("ordinary host refused", err)
+ }
+ })
+ }
+}
+
+func TestPlan14BoundCapabilitiesNoCensus(t *testing.T) {
+ oldOpen, oldShapes := openClaimUnitBindingsRead, productionClaimUnitShapes
+ defer func() { openClaimUnitBindingsRead, productionClaimUnitShapes = oldOpen, oldShapes }()
+ openClaimUnitBindingsRead = func() (rootstate.Store, error) { t.Fatal("no-census binding read"); return nil, os.ErrPermission }
+ productionClaimUnitShapes = func() (map[string]claimUnitShape, error) {
+ t.Fatal("no-census config read")
+ return nil, os.ErrPermission
+ }
+ calls := 0
+ prior := caps.Ops{CheckHost: func(string) error { calls++; return nil }}
+ if err := productionClaimCapsOps(prior, func() (bool, error) { return true, nil }).CheckHost("console.example"); err != nil || calls != 1 {
+ t.Fatal(err, calls)
+ }
+ if err := productionClaimCapsOps(prior, func() (bool, error) { return false, os.ErrPermission }).CheckHost("console.example"); err == nil || calls != 1 {
+ t.Fatal("census error reached effect", err, calls)
+ }
+}
+
+func TestPlan14UnitShapePolicyProductionModes(t *testing.T) {
+ b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "")
+ if _, err := b.establish(t.Context(), in.Host); err != nil {
+ t.Fatal(err)
+ }
+ oldOpen, oldShapes := openClaimUnitBindingsRead, productionClaimUnitShapes
+ defer func() { openClaimUnitBindingsRead, productionClaimUnitShapes = oldOpen, oldShapes }()
+ opens := 0
+ openClaimUnitBindingsRead = func() (rootstate.Store, error) { opens++; return b.state, nil }
+ productionClaimUnitShapes = func() (map[string]claimUnitShape, error) { return map[string]claimUnitShape{in.Host: shape}, nil }
+ for _, mode := range []string{"instance-control", "adopt"} {
+ row := shape.route()
+ p := productionClaimUnitShapePolicy(mode, func(string) (*registry.Route, error) { return &row, nil })
+ s := hostbox.Selection{Host: in.Host, Box: in.Box, Captured: hostguard.Captured{Incarnation: in.Incarnation, Stamp: in.Stamp}}
+ if err := p(t.Context(), nil, s, hostwrite.Identity{}); err != nil {
+ t.Fatal(mode, err)
+ }
+ row.Unit = ""
+ if err := p(t.Context(), nil, s, hostwrite.Identity{}); err == nil {
+ t.Fatal(mode, "cleared binding")
+ }
+ }
+ if opens != 4 {
+ t.Fatal("root did not read independent binding", opens)
+ }
+ openClaimUnitBindingsRead = func() (rootstate.Store, error) { return nil, os.ErrPermission }
+ p := productionClaimUnitShapePolicy("adopt", func(string) (*registry.Route, error) { r := shape.route(); return &r, nil })
+ if p(t.Context(), nil, hostbox.Selection{Host: in.Host}, hostwrite.Identity{}) == nil {
+ t.Fatal("unavailable root read permitted")
+ }
+}
+
+func TestPlan14BoundRestartShapeRefusal(t *testing.T) {
+ for _, op := range []string{"restart", "rollback", "checked"} {
+ for _, shape := range []string{"unchanged", "cleared", "ordinary", "sibling", "backing", "socket", "missing"} {
+ t.Run(op+"/"+shape, func(t *testing.T) {
+ reg := restartTestRegistry(t)
+ b, in, bound := plan14UnitBindingFixture(t, "console.service", "", "")
+ if _, err := b.establish(t.Context(), in.Host); err != nil {
+ t.Fatal(err)
+ }
+ row := bound.route()
+ switch shape {
+ case "cleared":
+ row.Unit = ""
+ case "ordinary":
+ row.Unit = "carlos-app@" + in.Host + ".service"
+ case "sibling":
+ row.Unit = "sibling.service"
+ case "backing":
+ row.Backing = "unit"
+ case "socket":
+ row.Socket = "/run/carlos/other.sock"
+ }
+ if shape != "missing" {
+ if err := reg.Upsert(row); err != nil {
+ t.Fatal(err)
+ }
+ }
+ calls := 0
+ oldOpen, oldShapes := openClaimUnitBindingsRead, productionClaimUnitShapes
+ defer func() { openClaimUnitBindingsRead, productionClaimUnitShapes = oldOpen, oldShapes }()
+ openClaimUnitBindingsRead = func() (rootstate.Store, error) { return b.state, nil }
+ productionClaimUnitShapes = func() (map[string]claimUnitShape, error) { return map[string]claimUnitShape{in.Host: bound}, nil }
+ checks := rootRestartChecks{unitShape: func(ctx context.Context, host string, rt *registry.Route) error {
+ calls++
+ return rootClaimRestartShape(ctx, host, rt)
+ }}
+ sys := &fakeUnits{}
+ var err error
+ switch op {
+ case "restart":
+ err = rootRestartRouteWith(reg, sys, t.TempDir(), func(string) string { return "" }, nil, checks)(in.Host)
+ case "rollback":
+ err = rollbackUnitRestart(reg, sys, t.TempDir(), func(string) string { return "" }, checks)(in.Host)
+ case "checked":
+ if shape == "missing" {
+ row = registry.Route{Host: in.Host}
+ }
+ err = checkedUnitRestart(sys, t.TempDir(), func(string) string { return "" }, nil, checks)(row)
+ }
+ if err == nil || calls != 1 || len(sys.restarts) != 0 {
+ t.Fatal("shape bypass", err, calls, sys.restarts)
+ }
+ })
+ }
+ }
+}
diff --git a/internal/host/claimunit_production.go b/internal/host/claimunit_production.go
index 96006cf13..e3ba8c5db 100644
--- a/internal/host/claimunit_production.go
+++ b/internal/host/claimunit_production.go
@@ -3,6 +3,8 @@ package host
import (
"context"
"errors"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "log"
"os"
"sort"
@@ -38,17 +40,10 @@ func bindProductionClaimUnits(ctx context.Context, h *ClaimRootWithLaunch, a *Cl
if !ok {
return os.ErrPermission
}
- shapes, err := productionClaimUnitShapes()
- if err != nil {
- return err
- }
- for _, shape := range shapes {
- if !claimHexDigest(shape.Program) {
- return os.ErrPermission
- }
- if _, err := readClaimUnitProgram(shape); err != nil {
- return err
- }
+ shapes, configErr := productionClaimUnitShapes()
+ if configErr != nil {
+ log.Print(configErr)
+ shapes = map[string]claimUnitShape{}
}
state, err := openClaimUnitBindings()
if err != nil {
@@ -103,36 +98,12 @@ func bindProductionClaimUnits(ctx context.Context, h *ClaimRootWithLaunch, a *Cl
unit.BoundHost, unit.BoundUnit = host, binding.Shape.Unit
return unit.Gone(ctx, host, "")
}, state: materialState, read: files.read, create: files.create, replace: files.replace}
+ predecessor := claimPredecessorKernel{ctl: base.Systemctl, group: claimPredecessorGroup}
+ material.predecessor, material.stopPredecessor = predecessor.capture, predecessor.stop
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 {
- 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: func(shape claimUnitShape) error {
+ dispatch := claimUnitDispatch{base: base, bindings: b, shapes: productionClaimUnitShapes, effective: func(ctx context.Context, shape claimUnitShape) error {
+ return claimEffectiveUnit(ctx, shape, base.Systemctl)
+ }, definition: func(shape claimUnitShape) error {
binding, err := b.read(shape.Host)
if err != nil {
return err
@@ -144,17 +115,22 @@ func bindProductionClaimUnits(ctx context.Context, h *ClaimRootWithLaunch, a *Cl
}}
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)
- }
+ h.Launch.boundReady = func(ctx context.Context, host string, started instancectl.ClaimUnitStarted) error {
+ binding, err := b.read(host)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return err
}
- return nil
+ if binding.Shape.Unit != started.Unit {
+ return os.ErrPermission
+ }
+ binding, err = b.check(ctx, host, nil)
+ if err != nil {
+ return err
+ }
+ return claimBoundUnitReady(ctx, started, binding.Shape)
}
h.Root.retireBoundUnit = func(ctx context.Context, target hostguard.FenceTarget) error {
if err := retireClaimBoundUnit(ctx, target, b, material, dispatch.Gone); err != nil {
@@ -182,5 +158,69 @@ func bindProductionClaimUnits(ctx context.Context, h *ClaimRootWithLaunch, a *Cl
}
return errors.Join(err, state.Close(), materialState.Close())
}
+ if h.Root.Reconcile.Retirement.JournalDir != "" {
+ journals, e := hostbox.SavedRetirementJournals(h.Root.Reconcile.Retirement.JournalDir)
+ if e != nil && !errors.Is(e, os.ErrNotExist) {
+ log.Print(e)
+ }
+ for _, journal := range journals {
+ if journal.Target != nil {
+ if e := h.Root.retire(ctx, *journal.Target); e != nil {
+ log.Print(e)
+ }
+ }
+ }
+ }
+ hosts := make([]string, 0, len(shapes))
+ for host := range shapes {
+ hosts = append(hosts, host)
+ }
+ sort.Strings(hosts)
+ for _, host := range hosts {
+ shape := shapes[host]
+ if !claimHexDigest(shape.Program) {
+ log.Print(os.ErrPermission)
+ continue
+ }
+ if _, e := readClaimUnitProgram(shape); e != nil {
+ log.Print(e)
+ continue
+ }
+ next, e := b.current(ctx, host)
+ if e != nil {
+ log.Print(e)
+ continue
+ }
+ if e = guard.WithLocal(ctx, host, func() error {
+ current, e := guard.Root.Installation(ctx, host, hostguard.Captured{Incarnation: next.Owner.Incarnation, Stamp: next.Owner.Stamp})
+ if e != nil {
+ return e
+ }
+ if unitBindingOwner(current) != next.Owner {
+ return os.ErrPermission
+ }
+ binding, e := b.establish(ctx, host, next)
+ if e != nil {
+ return e
+ }
+ if e = material.install(ctx, binding); e != nil {
+ return e
+ }
+ if _, e = base.Systemctl(ctx, "daemon-reload"); e != nil {
+ return e
+ }
+ if e = claimEffectiveUnit(ctx, binding.Shape, base.Systemctl); e != nil {
+ return e
+ }
+ return projectClaimBoundSocket(binding.Shape)
+ }); e != nil {
+ log.Print(e)
+ }
+ }
+ if len(hosts) != 0 {
+ if _, e := base.Systemctl(ctx, "daemon-reload"); e != nil {
+ log.Print(e)
+ }
+ }
return nil
}
diff --git a/internal/host/claimunit_retire.go b/internal/host/claimunit_retire.go
index e973cc531..a7d69ad93 100644
--- a/internal/host/claimunit_retire.go
+++ b/internal/host/claimunit_retire.go
@@ -26,11 +26,7 @@ func (c claimBoundUnitCleanup) WithCleanup(ctx context.Context, t hostguard.Fenc
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
+ return nil
}
if err != nil {
return err
diff --git a/internal/host/claimunit_startup_test.go b/internal/host/claimunit_startup_test.go
new file mode 100644
index 000000000..675df560d
--- /dev/null
+++ b/internal/host/claimunit_startup_test.go
@@ -0,0 +1,79 @@
+package host
+
+import (
+ "context"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func TestPlan14UnitStartupIsolation(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 }
+ if _, err := b.establish(t.Context(), in.Host); err != nil {
+ t.Fatal(err)
+ }
+ d := claimUnitDispatch{bindings: b, shapes: func() (map[string]claimUnitShape, error) { return nil, os.ErrPermission }}
+ if unit, _, err := d.Placement("sibling.example"); err != nil || unit != instancectl.UnitName(instancectl.RoleRun, "sibling.example") {
+ t.Fatal("config corruption blocked ordinary cleanup", unit, err)
+ }
+ if _, err := d.forStart(t.Context(), in.Host); err == nil {
+ t.Fatal("corrupt config launched named unit")
+ }
+ if _, err := d.forStart(t.Context(), "sibling.example"); err != nil {
+ t.Fatal("corrupt named config blocked ordinary launch", err)
+ }
+}
+
+func TestPlan14ProductionUnitStartupIsolation(t *testing.T) {
+ for _, corrupt := range []bool{false, true} {
+ t.Run(map[bool]string{false: "bad-program", true: "corrupt-config"}[corrupt], func(t *testing.T) {
+ f, _, _ := generationLaunchFixture(t)
+ b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "")
+ if _, err := b.establish(t.Context(), in.Host); err != nil {
+ t.Fatal(err)
+ }
+ oldOpen, oldMaterials, oldShapes := openClaimUnitBindings, openClaimUnitMaterializations, productionClaimUnitShapes
+ defer func() {
+ openClaimUnitBindings, openClaimUnitMaterializations, productionClaimUnitShapes = oldOpen, oldMaterials, oldShapes
+ }()
+ openClaimUnitBindings = func() (rootstate.Store, error) { return b.state, nil }
+ openClaimUnitMaterializations = func() (rootstate.Store, error) { return &rollbackDisk{dir: t.TempDir()}, nil }
+ productionClaimUnitShapes = func() (map[string]claimUnitShape, error) {
+ if corrupt {
+ return nil, os.ErrPermission
+ }
+ return map[string]claimUnitShape{in.Host: shape}, nil
+ }
+ base := instancectl.ClaimUnits{Systemctl: func(_ context.Context, args ...string) (string, error) {
+ if len(args) != 1 || args[0] != "daemon-reload" {
+ t.Fatal("startup touched a unit", args)
+ }
+ return "", nil
+ }}
+ f.root.Units = base
+ wrapped := ClaimRootWithLaunch{Launch: f.root}
+ wrapped.Root.Stop.Coordinator.Kernel = claimUnitStopKernel{Units: base}
+ a := &ClaimAssembly{}
+ if err := bindProductionClaimUnits(t.Context(), &wrapped, a); err != nil {
+ t.Fatal("one unit killed root startup", err)
+ }
+ defer a.Close(t.Context())
+ for _, units := range []ClaimLaunchUnits{wrapped.Launch.Units, wrapped.Root.Stop.Coordinator.Kernel.(claimUnitStopKernel).Units} {
+ p := units.(interface {
+ Placement(string) (string, string, error)
+ })
+ if unit, _, err := p.Placement("ordinary.example"); err != nil || unit != instancectl.UnitName(instancectl.RoleRun, "ordinary.example") {
+ t.Fatal("ordinary host blocked", unit, err)
+ }
+ if unit, _, err := p.Placement(in.Host); err != nil || unit != shape.Unit {
+ t.Fatal("retained cleanup placement lost", unit, err)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/host/plan14_bound_retirement_root_linux_test.go b/internal/host/plan14_bound_retirement_root_linux_test.go
new file mode 100644
index 000000000..e9225df39
--- /dev/null
+++ b/internal/host/plan14_bound_retirement_root_linux_test.go
@@ -0,0 +1,139 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func TestPlan14CombinedBoundRetirementRootRunner(t *testing.T) {
+ for _, unit := range []string{"tcp", "socket-program", "backing-program"} {
+ t.Run(unit, func(t *testing.T) { plan14InstalledClaimLaunch(t, "combined:materialized:"+unit) })
+ }
+}
+
+// Installation, admission and the completed receipt are fixture authority.
+// The caller has already joined the real controller and stopped both real unit
+// invocations. This adds the production retirement composition and registry.
+func plan14CombinedBoundRetirement(t *testing.T, dir string, f *launchFixture, m *claimUnitMaterializer) (func() error, func()) {
+ t.Helper()
+ t.Setenv("CARLOS_HOST_CLAIM", "true")
+ target := f.target
+ target.FenceID = "combined-retirement"
+ observed, err := f.claims.Read(t.Context(), target.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ target.ClaimETag, target.ClaimGen = observed.ETag, observed.Claim.Gen
+ if target, err = f.root.Guard.BeginFence(t.Context(), target); err != nil {
+ t.Fatal("combined fence", err)
+ }
+ at := time.Now().UTC()
+ receipt := StopReceipt{V: 1, Kind: "stopped", ID: "combined-receipt", FenceID: target.FenceID, Host: target.Host, Incarnation: target.Incarnation, InstallationStamp: target.Stamp, WriterSession: target.Session, Region: target.Region, ClaimETag: target.ClaimETag, ClaimGen: target.ClaimGen, OperationID: target.Op, Phase: target.Phase, ObligationID: target.ObligationID, Box: target.Box, Enrollment: target.Enrollment, BootID: target.BootID, ControllerID: target.ControllerID, DirectoryRevision: target.DirectoryRevision, EvidenceAt: at}
+ raw, _ := json.Marshal(receipt)
+ if err = f.root.Guard.CompleteFence(t.Context(), target, raw, at, func(ctx context.Context, t hostguard.FenceTarget, body []byte, when time.Time) error {
+ return verifyRetirementReceipt(ctx, hostguard.FenceReceipt{Target: t, Bytes: body, EvidenceAt: when})
+ }); err != nil {
+ t.Fatal(err)
+ }
+ reg, err := registry.Open(filepath.Join(dir, "combined-registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { reg.Close() })
+ inventory, err := hostbox.OpenStopInventoryDB(filepath.Join(dir, "combined-inventory.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { inventory.Close() })
+ b, err := f.root.unitBindings.read(target.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ row := b.Shape.route()
+ row.ClaimOwnership = registry.ClaimOwnership{ClaimIncarnation: target.Incarnation, ClaimStamp: target.Stamp, ClaimOwner: "combined-owner", ClaimRowID: "combined-route"}
+ if err = reg.Upsert(row); err != nil {
+ t.Fatal(err)
+ }
+ timer := registry.Timer{Host: target.Host, Name: "backup", Kind: "cron", Registration: "timer-1", ClaimOwnership: row.ClaimOwnership}
+ timer.ClaimRowID = "timer-1"
+ if err = reg.ReplaceTimers(target.Host, []registry.Timer{timer}); err != nil {
+ t.Fatal(err)
+ }
+ in := hostbox.StopInventory{Complete: true, Host: target.Host, Incarnation: target.Incarnation, InstallationStamp: target.Stamp, Box: target.Box, Enrollment: target.Enrollment, DirectoryRevision: target.DirectoryRevision, Entries: []hostbox.StopInventoryEntry{
+ {Kind: "route", ArtifactID: "route", ManagedRoot: "registry", RelativePath: target.Host, RowIdentity: row.ClaimRowID, Ownership: row.ClaimOwner, Materialization: "materialized"},
+ {Kind: "timer", ArtifactID: "timer", ManagedRoot: "registry", RelativePath: target.Host + "/backup", RowIdentity: "timer-1", Ownership: row.ClaimOwner, Materialization: "materialized"},
+ }}
+ if err = inventory.WriteStopInventory(t.Context(), in); err != nil {
+ t.Fatal(err)
+ }
+ journal := filepath.Join(dir, "combined-retirement")
+ root := ClaimRootHTTP{Stop: ClaimStopHTTP{Coordinator: ClaimStopCoordinator{Guard: f.root.Guard}}, Roots: map[string]string{"data": "/data"}, Reconcile: hostbox.ReconcileAdapter{Inventory: inventory, Registry: reg, Retirement: hostbox.TypedRetirement{JournalDir: journal}}, retireBoundUnit: func(ctx context.Context, t hostguard.FenceTarget) error {
+ return retireClaimBoundUnit(ctx, t, f.root.unitBindings, *m, f.root.Units.Gone)
+ }}
+ retire := func() error {
+ err := root.retire(t.Context(), target)
+ records, e := generationRetirementRecords(t.Context(), &rollbackDisk{dir: journal})
+ if e == nil && len(records) == 1 && records[0].Complete {
+ return nil
+ }
+ return err
+ }
+ verify := func() {
+ got, e := reg.Get(target.Host)
+ if e != nil || got != nil {
+ t.Fatal("route not retired", got, e)
+ }
+ timers, e := reg.TimersFor(target.Host)
+ if e != nil || len(timers) != 0 {
+ t.Fatal("timer not retired", timers, e)
+ }
+ for _, leaf := range []string{"launch.json", "permit.json", "exec.sock"} {
+ if _, e := os.Lstat(filepath.Join("/run/carlos-control/exe/.claim", provision.InstanceID(target.Host), leaf)); !os.IsNotExist(e) {
+ t.Fatal("runtime environment retained", leaf, e)
+ }
+ }
+ before, e := os.ReadFile(filepath.Join(journal, target.FenceID+".json"))
+ if e != nil {
+ t.Fatal(e)
+ }
+ row.ClaimStamp++
+ row.ClaimRowID = "successor-route"
+ row.ClaimOwner = "successor-owner"
+ timer.ClaimOwnership = row.ClaimOwnership
+ timer.ClaimRowID = "successor-timer"
+ timer.Registration = "successor-timer"
+ if e = reg.Upsert(row); e != nil {
+ t.Fatal(e)
+ }
+ if e = reg.ReplaceTimers(target.Host, []registry.Timer{timer}); e != nil {
+ t.Fatal(e)
+ }
+ if e = retire(); e != nil {
+ t.Fatal("local completed replay", e)
+ }
+ got, e = reg.Get(target.Host)
+ if e != nil || got == nil || got.ClaimRowID != row.ClaimRowID {
+ t.Fatal("successor route changed", e)
+ }
+ timers, e = reg.TimersFor(target.Host)
+ if e != nil || len(timers) != 1 || timers[0].Registration != timer.Registration {
+ t.Fatal("successor timer changed", e)
+ }
+ after, e := os.ReadFile(filepath.Join(journal, target.FenceID+".json"))
+ if e != nil || string(before) != string(after) {
+ t.Fatal("completed retirement changed", e)
+ }
+ }
+ return retire, verify
+}
diff --git a/internal/host/plan14_installed_launch_root_linux_test.go b/internal/host/plan14_installed_launch_root_linux_test.go
index 2cc335811..d3889b1e4 100644
--- a/internal/host/plan14_installed_launch_root_linux_test.go
+++ b/internal/host/plan14_installed_launch_root_linux_test.go
@@ -82,7 +82,17 @@ func TestPlan14MaterializedBoundRouteRootRunner(t *testing.T) {
}
}
+func TestPlan14MigratedBoundRouteRootRunner(t *testing.T) {
+ for _, unit := range []string{"tcp", "socket-program", "backing-program"} {
+ t.Run(unit, func(t *testing.T) { plan14InstalledClaimLaunch(t, "migrated:materialized:"+unit) })
+ }
+}
+
func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
+ combined := strings.HasPrefix(boundUnit, "combined:")
+ boundUnit = strings.TrimPrefix(boundUnit, "combined:")
+ migrated := strings.HasPrefix(boundUnit, "migrated:")
+ boundUnit = strings.TrimPrefix(boundUnit, "migrated:")
materialized := strings.HasPrefix(boundUnit, "materialized:")
boundUnit = strings.TrimPrefix(boundUnit, "materialized:")
programMode := boundUnit == "tcp" || boundUnit == "socket-program" || boundUnit == "backing-program"
@@ -205,6 +215,12 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
if materialized {
unitPath = filepath.Join("/etc/systemd/system", unit)
}
+ legacyDefinition := []byte("[Service]\nType=simple\nUser=carlos\nKillMode=control-group\nExecStart=/bin/sh -c 'sleep infinity & wait'\n")
+ if migrated {
+ if err := os.WriteFile(unitPath, legacyDefinition, 0644); 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 {
@@ -227,6 +243,11 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
if out, err := ctl(t.Context(), "daemon-reload"); err != nil {
t.Fatal(out, err)
}
+ if migrated {
+ if out, err := ctl(t.Context(), "start", unit); err != nil {
+ t.Fatal(out, err)
+ }
+ }
// Use a real controller lifetime, which must be joined before exact release.
controller := exec.Command("/bin/sleep", "240")
if err := controller.Start(); err != nil {
@@ -292,6 +313,9 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
if strings.HasPrefix(boundUnit, "carlos-app@") {
shape.Backing = "unit"
}
+ if migrated {
+ shape.PredecessorDigest = claimUnitDigest(legacyDefinition)
+ }
if programMode {
shape.Program = hex.EncodeToString(programSum[:])
if materialized && !tcpMode {
@@ -346,8 +370,13 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
if err != nil {
t.Fatal(err)
}
+ if migrated {
+ kernel := claimPredecessorKernel{ctl: ctl, group: claimPredecessorGroup}
+ material.predecessor, material.stopPredecessor = kernel.capture, kernel.stop
+ }
if err := material.install(t.Context(), binding); err != nil {
- t.Fatal(err)
+ debug, _ := ctl(t.Context(), "show", unit, "-p", "ActiveState,InvocationID,ControlGroup,FragmentPath,DropInPaths")
+ t.Fatalf("migration/materialization: %v; unit: %s", err, debug)
}
if err := projectClaimBoundSocket(binding.Shape); err != nil {
t.Fatal(err)
@@ -365,7 +394,14 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
}
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)
+ if err := material.verify(binding); err != nil {
+ return err
+ }
+ if err := claimEffectiveUnit(t.Context(), s, ctl); err != nil {
+ t.Logf("effective unit refused: %#v", err)
+ return err
+ }
+ return nil
}
// Dependencies and output are runner adapters; all isolation settings
// and the permanent no-reservation refusal remain in the unit.
@@ -380,7 +416,8 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
}}
}
if materialized {
- f.root.boundReady = func(ctx context.Context, started instancectl.ClaimUnitStarted) error {
+ plan14BoundOverrideRefusals(t, f.root.Units.(claimUnitDispatch), host, unit, ctl)
+ f.root.boundReady = func(ctx context.Context, host string, started instancectl.ClaimUnitStarted) error {
b, err := f.root.unitBindings.check(ctx, host, nil)
if err != nil {
return err
@@ -623,6 +660,13 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
}
checkSibling()
if material != nil {
+ retireUnit := func() error {
+ return retireClaimBoundUnit(t.Context(), f.target, f.root.unitBindings, *material, f.root.Units.Gone)
+ }
+ verifyCombined := func() {}
+ if combined {
+ retireUnit, verifyCombined = plan14CombinedBoundRetirement(t, dir, f, material)
+ }
original, err := os.ReadFile(unitPath)
if err != nil {
t.Fatal(err)
@@ -639,7 +683,7 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
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 {
+ if err := retireUnit(); err == nil {
t.Fatal("same-byte successor retired")
}
after, err := os.ReadFile(unitPath)
@@ -652,9 +696,10 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
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 {
+ if err := retireUnit(); err != nil {
t.Fatal("unit retirement", err)
}
+ verifyCombined()
if _, err := ctl(t.Context(), "daemon-reload"); err != nil {
t.Fatal(err)
}
@@ -688,6 +733,37 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
}
}
+func plan14BoundOverrideRefusals(t *testing.T, dispatch claimUnitDispatch, host, unit string, ctl func(context.Context, ...string) (string, error)) {
+ t.Helper()
+ dir := filepath.Join("/etc/systemd/system", unit+".d")
+ if err := os.Mkdir(dir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ for _, attack := range []struct{ name, body string }{
+ {"90-operator.conf", "[Service]\nExecStart=\nExecStart=/usr/bin/true\n"},
+ {"90-user.conf", "[Service]\nUser=root\n"},
+ {"10-carlos-capabilities.conf", "[Service]\nIPAddressAllow=any\n"},
+ } {
+ path := filepath.Join(dir, attack.name)
+ if err := os.WriteFile(path, []byte(attack.body), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ctl(t.Context(), "daemon-reload"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := dispatch.forStart(t.Context(), host); err == nil {
+ t.Fatal("effective drop-in accepted", attack.name)
+ }
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := ctl(t.Context(), "daemon-reload"); err != nil {
+ t.Fatal(err)
+ }
+}
+
func plan14InstalledRetire(t *testing.T, dir string) {
state, err := rootstate.Open(filepath.Join(dir, "fences"), false, false)
if err != nil {
diff --git a/internal/host/plan14_launch_verifier_test.go b/internal/host/plan14_launch_verifier_test.go
index aead7a7ae..9b8b55172 100644
--- a/internal/host/plan14_launch_verifier_test.go
+++ b/internal/host/plan14_launch_verifier_test.go
@@ -13,6 +13,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/rootparticipation"
"github.com/carlosframework/platform/internal/rootstate"
@@ -88,6 +89,10 @@ func TestPlan14LaunchVerifierCallerOrder(t *testing.T) {
}
floor := hostfloor.Disk{Dir: t.TempDir(), Participation: l}
a := &systemApplier{participation: l, optDir: t.TempDir(), dataDir: t.TempDir()}
+ if name == "guard-close" || name == "guard-rollback" {
+ armLocalFloor(t, a.dataDir, "v0.30.0")
+ a.openRegistry = func() (*registry.Registry, error) { return registry.Open(filepath.Join(a.dataDir, "registry.db")) }
+ }
run := func(ctx context.Context) error {
switch name {
case "idle":
@@ -109,7 +114,7 @@ func TestPlan14LaunchVerifierCallerOrder(t *testing.T) {
case "system-clear":
return a.clearUpdateBegun(ctx)
case "guard-close", "guard-rollback":
- return a.activationGuard(ctx, "new", "old", func(ctx context.Context) error { return a.clearUpdateBegun(ctx) })
+ return a.activationGuard(ctx, "v0.32.0", "v0.31.0", func(ctx context.Context) error { return a.clearUpdateBegun(ctx) })
case "system-recovery":
return a.controllersRecovered(ctx)
}
diff --git a/internal/host/rootrestart.go b/internal/host/rootrestart.go
index 41db59b68..e2c314eb7 100644
--- a/internal/host/rootrestart.go
+++ b/internal/host/rootrestart.go
@@ -22,6 +22,7 @@ import (
)
type rootRestartChecks struct {
+ unitShape func(context.Context, string, *registry.Route) error
preserveOutcome bool
history func() (*release.RootHistoryPass, error)
readLive func(string) (string, error)
@@ -67,7 +68,7 @@ func productionRootRestartChecks(sys unitSystem, optDir string) rootRestartCheck
cached = productionProofBindingSource(s, sys, optDir)
return cached, nil
}
- return rootRestartChecks{legacy: legacyRootRestart, legacyLog: &sync.Once{}, targetFile: rootstate.OpenTrustedFile, started: &sync.Map{}, readLive: rootstate.ReadTrustedLink,
+ return rootRestartChecks{unitShape: rootClaimRestartShape, legacy: legacyRootRestart, legacyLog: &sync.Once{}, targetFile: rootstate.OpenTrustedFile, started: &sync.Map{}, readLive: rootstate.ReadTrustedLink,
recovered: func(ctx context.Context, b proofBinding) (proofBinding, error) {
s, err := source(ctx)
if err != nil {
@@ -166,6 +167,9 @@ func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) strin
c.noteLegacy()
return legacyCheckedUnitRestart(sys, optDir, getenv, candidate, settled, c)
}
+ if err := c.checkUnitShape(context.Background(), candidate.Host, &candidate); err != nil {
+ return err
+ }
if c.attempts == nil {
return os.ErrPermission
}
@@ -423,6 +427,9 @@ func rollbackUnitRestart(reg *registry.Registry, sys unitSystem, optDir string,
return func(host string) (result error) {
c := c
rt, routeErr := reg.Get(host)
+ if err := c.checkUnitShape(context.Background(), host, rt); err != nil {
+ return err
+ }
if rt != nil && (rt.Unit == "" || (c.legacyStart != nil && rt.Hibernate)) {
return nil
}
@@ -542,6 +549,9 @@ func rootRestartRouteWith(reg *registry.Registry, sys unitSystem, optDir string,
restart := checkedUnitRestart(sys, optDir, getenv, settled, checks)
return func(host string) error {
rt, err := reg.Get(host)
+ if shapeErr := checks.checkUnitShape(context.Background(), host, rt); shapeErr != nil {
+ return shapeErr
+ }
if err != nil || rt == nil || rt.Unit == "" {
return restartOrdinaryUnit(sys, host)
}
diff --git a/internal/instancectl/claimunit_bound_stop.go b/internal/instancectl/claimunit_bound_stop.go
index 5fa33820b..f4b4c01be 100644
--- a/internal/instancectl/claimunit_bound_stop.go
+++ b/internal/instancectl/claimunit_bound_stop.go
@@ -17,5 +17,17 @@ func (u ClaimUnits) StopInvocation(ctx context.Context, host, invocation string)
if state["InvocationID"] != invocation || state["ControlGroup"] != "/"+group {
return ErrClaimUnitBusy
}
- return u.Stop(ctx, host)
+ if err := u.Stop(ctx, host); err != nil {
+ return err
+ }
+ state, err = u.show(ctx, unit, "InvocationID", "ControlGroup")
+ if err != nil {
+ return err
+ }
+ // systemctl stops by name. A replacement between the check and stop
+ // makes the result uncertain; never issue a successful stop receipt.
+ if state["InvocationID"] != "" && state["InvocationID"] != invocation || state["ControlGroup"] != "" && state["ControlGroup"] != "/"+group {
+ return ErrClaimUnitBusy
+ }
+ return nil
}
diff --git a/internal/instancectl/claimunit_bound_test.go b/internal/instancectl/claimunit_bound_test.go
index d85ed4670..0a0d4a1f2 100644
--- a/internal/instancectl/claimunit_bound_test.go
+++ b/internal/instancectl/claimunit_bound_test.go
@@ -89,3 +89,27 @@ func TestPlan14BoundUnitExactStop(t *testing.T) {
t.Fatal(ctl.joined())
}
}
+
+func TestPlan14BoundStopReportsInvocationRace(t *testing.T) {
+ for _, changed := range []string{"invocation", "group"} {
+ t.Run(changed, func(t *testing.T) {
+ u, ctl, in, _ := claimUnitFixture(t)
+ u.BoundHost, u.BoundUnit = in.Host, "console.service"
+ ctl.group = "/" + BoundClaimUnitControlGroup(in.Host, u.BoundUnit)
+ ctl.invocation, ctl.state = "old", "active"
+ u.Systemctl = func(ctx context.Context, args ...string) (string, error) {
+ if args[0] == "stop" {
+ if changed == "invocation" {
+ ctl.invocation = "replacement"
+ } else {
+ ctl.group = "/sibling.slice/console.service"
+ }
+ }
+ return ctl.run(ctx, args...)
+ }
+ if err := u.StopInvocation(t.Context(), in.Host, "old"); !errors.Is(err, ErrClaimUnitBusy) {
+ t.Fatal("uncertain stop reported success", err)
+ }
+ })
+ }
+}
diff --git a/internal/provision/testdata/plan14-root-reads-e.tsv b/internal/provision/testdata/plan14-root-reads-e.tsv
index e1bf4aebc..91585a6b7 100644
--- a/internal/provision/testdata/plan14-root-reads-e.tsv
+++ b/internal/provision/testdata/plan14-root-reads-e.tsv
@@ -1,6 +1,6 @@
# Track E integration after merge3. Earlier baselines, overlays and legacy fingerprints are immutable.
-internal/host/agent.go runAdoptUnderRootRelease authority B3,E1,E2 E13: signed recovery allows unknown history but preserves usable refusals, including retained originals; verified later promotion clears only D1. Health recording is independent of program recording; minting requires a census. Fixtures: TestPlan14RecoveryUnknownHistory, TestPlan14RecoveryKnownRefusal, TestPlan14RecoveryOriginalRefusalWithUnknownHistory, TestPlan14RootHistoryVerifiedRetryBeforeAttempt, TestPlan14ProgramFailureStillRecordsHealth, TestPlan14RootHistoryMintCensus. 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. E19: both destructive readers use the root reservation snapshot under existing release exclusion. Exact artifact and containing-tree identities are retained; missing completeness refuses destruction. Production installation/launch/kernel completeness and verified retirement remain unfinished. Fixtures: TestPlan14GenerationDeletionCannotUnpin, TestPlan14GenerationSnapshotCompleteness, TestPlan14GenerationSnapshotPermitsUnreservedRestage, TestPlan14GenerationSnapshotPinsContainingTree, TestPlan14GenerationSnapshotEmptyIsUnknown, TestPlan14GenerationConsumerMode. whole:Route@10766,whole:Route@18194,whole:Route@18230,whole:Route@8068 71b09c1fdcab8e9dbb9c6f255cfa72acbf8a17f4ab2d959d8d2b7d7d0afe3ca9
-internal/host/agent.go runAdoptWithEnsureSystemUnits authority B3,C,D,E1,E2,E3,E4,F,J1 PROPOSED E45 / R-E45-3: root checks census presence before opening release exclusion; absent census uses main adoption with no release directory or lock. Retained census remains protected with either flag value. 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@176 51a47bdd9d229f9177ef5e278b4e8b6d3bba125b2db6c1b96b81317a8f495373
+internal/host/agent.go runAdoptUnderRootRelease authority B3,E1,E2 E13: signed recovery allows unknown history but preserves usable refusals, including retained originals; verified later promotion clears only D1. Health recording is independent of program recording; minting requires a census. Fixtures: TestPlan14RecoveryUnknownHistory, TestPlan14RecoveryKnownRefusal, TestPlan14RecoveryOriginalRefusalWithUnknownHistory, TestPlan14RootHistoryVerifiedRetryBeforeAttempt, TestPlan14ProgramFailureStillRecordsHealth, TestPlan14RootHistoryMintCensus. 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. E19: both destructive readers use the root reservation snapshot under existing release exclusion. Exact artifact and containing-tree identities are retained; missing completeness refuses destruction. Production installation/launch/kernel completeness and verified retirement remain unfinished. Fixtures: TestPlan14GenerationDeletionCannotUnpin, TestPlan14GenerationSnapshotCompleteness, TestPlan14GenerationSnapshotPermitsUnreservedRestage, TestPlan14GenerationSnapshotPinsContainingTree, TestPlan14GenerationSnapshotEmptyIsUnknown, TestPlan14GenerationConsumerMode. PROPOSED E50: Root adoption installs the census-gated capability refusal before release convergence; the existing no-census path retains its effects. whole:Route@11059,whole:Route@18589,whole:Route@18625,whole:Route@8361 a960d1d5c9e3a8e90a9d330d44797b555757fd8af02305739b740368b41d11b7
+internal/host/agent.go runAdoptWithEnsureSystemUnits authority B3,C,D,E1,E2,E3,E4,F,J1 PROPOSED E45 / R-E45-3: root checks census presence before opening release exclusion; absent census uses main adoption with no release directory or lock. Retained census remains protected with either flag value. PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending. PROPOSED E50: Root carries its census probe from the outer release gate into restart and capability callbacks; an injected absent census and the production probe have the same path. file:Open@176 499f504fa785bf449cb4ccb606009d3436a79d3f1dad504e588b88459db799ab
internal/host/namedrecovery.go namedUnitRecovery.repair authority E1,J1 E13: signed recovery allows unknown history but preserves usable refusals, including retained originals; verified later promotion clears only D1. Health recording is independent of program recording; minting requires a census. Fixtures: TestPlan14RecoveryUnknownHistory, TestPlan14RecoveryKnownRefusal, TestPlan14RecoveryOriginalRefusalWithUnknownHistory, TestPlan14RootHistoryVerifiedRetryBeforeAttempt, TestPlan14ProgramFailureStillRecordsHealth, TestPlan14RootHistoryMintCensus. file:Open@2642,registry:Channel@1814,whole:Route@1060,whole:Route@1822 566bc61eafb118d9f6fba29228c372b18c8729549ed9a0bccf32c61b360eb9c3
internal/host/namedrecovery_active.go namedUnitRecovery.recover authority B3,E1 E13: signed recovery allows unknown history but preserves usable refusals, including retained originals; verified later promotion clears only D1. Health recording is independent of program recording; minting requires a census. Fixtures: TestPlan14RecoveryUnknownHistory, TestPlan14RecoveryKnownRefusal, TestPlan14RecoveryOriginalRefusalWithUnknownHistory, TestPlan14RootHistoryVerifiedRetryBeforeAttempt, TestPlan14ProgramFailureStillRecordsHealth, TestPlan14RootHistoryMintCensus. file:Open@5504,registry:Channel@2558,registry:Unit@216,registry:Unit@233,registry:Version@3618,registry:Version@3724,whole:Route@1478,whole:Route@202,whole:Route@2560,whole:Route@3544 dabec20a8ea93c7d72b6d6c3a30a1ae7c99ffcee4de4c0416fba699734739b92
internal/host/proofbinding.go proofBinding.authorizedRecovery authority B3,E1 Independent signed recovery authority and retained attempt outcomes. Fixtures: TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14IndependentRecoveryRetainsMintBarrier, TestPlan14ActiveRecoveryProcessCrash. a7a7cc9edf8437ed2a1fd4d4ed3d54e9ffab78b21ed3d2867a2254c7836e4f13
@@ -17,10 +17,10 @@ internal/host/roothistory_recovery.go historyRestartAttempt authority B3,E1 Exac
internal/host/roothistory_recovery.go linkRootHistory authority B3,E1 Exact signed recovery retirement binds namespace, complete outcome digest and a later root-verified success; pending originals and changed evidence remain restrictive. Independent settlement publishes status without controlling effects. Fixtures: TestPlan14HistoryExactRecoveryRetirement, TestPlan14HistoryRecoveryRetirementAuthority, TestPlan14HistoryRetirementVisibleSync, TestPlan14HistoryRetirementProcessCrash, TestPlan14RecoveryRetirementProductionCallback, TestPlan14IndependentRecoveryPublishesStatus. file:Prepare@121,registry:Account@374,registry:App@412,whole:Route@376,whole:Route@414 a60324796d2b25b3b130bb0439bbd9a415b679e6b51bc92a44f23f77e2d93e1e
internal/host/roothistory_recovery.go resumeHistoryRestart authority B3,E1 Exact signed recovery retirement binds namespace, complete outcome digest and a later root-verified success; pending originals and changed evidence remain restrictive. Independent settlement publishes status without controlling effects. Fixtures: TestPlan14HistoryExactRecoveryRetirement, TestPlan14HistoryRecoveryRetirementAuthority, TestPlan14HistoryRetirementVisibleSync, TestPlan14HistoryRetirementProcessCrash, TestPlan14RecoveryRetirementProductionCallback, TestPlan14IndependentRecoveryPublishesStatus. registry:Account@1202,registry:Account@2371,registry:App@1234,registry:App@2403,registry:Unit@1001,whole:Route@1005,whole:Route@1206,whole:Route@1238,whole:Route@2375,whole:Route@2407 8263cf0571efdd448b944fdb8492ce78ddf0223fdef18255eab97c40434eef60
internal/host/roothistory_recovery.go rootRestartChecks.settleHistory authority B3,E1 Exact signed recovery retirement binds namespace, complete outcome digest and a later root-verified success; pending originals and changed evidence remain restrictive. Independent settlement publishes status without controlling effects. Fixtures: TestPlan14HistoryExactRecoveryRetirement, TestPlan14HistoryRecoveryRetirementAuthority, TestPlan14HistoryRetirementVisibleSync, TestPlan14HistoryRetirementProcessCrash, TestPlan14RecoveryRetirementProductionCallback, TestPlan14IndependentRecoveryPublishesStatus. registry:Account@1008,registry:App@1055,registry:App@1779,whole:Route@1010,whole:Route@1057,whole:Route@1273,whole:Route@1419,whole:Route@1781 ff5cb1baa6f139194134de67159d12a5431672c32388314b221ed0da8065feb9
-internal/host/rootrestart.go checkedUnitRestart authority B3,J1,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. file:EvalSymlinks@1991,registry:Account@1560,registry:Account@2359,registry:App@1592,registry:App@2391,registry:Host@1262,registry:Host@2047,registry:Host@2934,registry:Host@3156,registry:Host@3828,registry:Host@703,registry:Unit@3710,registry:Unit@904,whole:Route@1568,whole:Route@1600,whole:Route@2367,whole:Route@2399,whole:Route@3718,whole:Route@479,whole:Route@831,whole:Route@912 610cd677cd158c30816eafef47c572405d9d2f82ead0c1b247de6605c118f3c7
-internal/host/rootrestart.go productionRootRestartChecks authority B3,J1,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. file:Open@1427,whole:Route@1260,whole:Route@2061,whole:Route@2328 729fe4f571f3eaf40d9880304c3d2db924625de3bbeba709cac03a67f5baa49e
+internal/host/rootrestart.go checkedUnitRestart authority B3,J1,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. PROPOSED E50: Root census path checks bound shape before any legacy systemctl effect; known bound units refuse until a reserved restart is implemented. file:EvalSymlinks@2102,registry:Account@1671,registry:Account@2470,registry:App@1703,registry:App@2502,registry:Host@1373,registry:Host@2158,registry:Host@3045,registry:Host@3267,registry:Host@3939,registry:Host@557,registry:Host@814,registry:Unit@1015,registry:Unit@3821,whole:Route@1023,whole:Route@1679,whole:Route@1711,whole:Route@2478,whole:Route@2510,whole:Route@3829,whole:Route@479,whole:Route@574,whole:Route@942 669cf6eb9b2f35ec08ab64d8ff0a3913400dcbba3097637413d48aa9ef95c64a
+internal/host/rootrestart.go productionRootRestartChecks authority B3,J1,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. PROPOSED E50: Root restart assembly wires the independent bound-unit refusal into adoption and rollback. file:Open@1461,whole:Route@1294,whole:Route@2095,whole:Route@2362 a78413b1410b2e4bdcfa6347e163033ed8b778107cb6e9aa0c1a115e054c1835
internal/host/rootrestart.go restartBoundUnit authority B3,J1 E9 merge ruling 6: E effects require a census; no-census recovery delegates to main or records evidence only. Fixtures: TestPlan14ERecoveryNoCensus, TestPlan14ERecoveryNoCensusResume, TestR2ERecoveryFlagWithoutCensus, TestPlan14ERecoveryRestartOutcome. Census-backed recovery retains captured authority and outcome precedence. registry:Account@2482,registry:Account@3996,registry:App@2518,registry:App@4036,registry:Host@1585,registry:Host@2301,registry:Host@650,registry:Unit@1071,registry:Unit@1139,registry:Unit@1894,registry:Unit@1962,registry:Unit@2004,registry:Unit@3299,registry:Unit@395,whole:Route@198,whole:Route@2488,whole:Route@2524,whole:Route@317,whole:Route@4004,whole:Route@4044,whole:Route@4255 4103258d590d273a341fe6570ec5cbb3684cf19a221987eb38401528bb47145e
-internal/host/rootrestart.go rollbackUnitRestart authority B3,J1,E1 Captured canonical rollback authority is unchanged. Durable verified outcome permits later history settlement without another restart. Fixtures: TestPlan14HistoryRollbackLink and TestPlan14HistoryUnavailableCannotBlockCapturedRollback. file:Read@1568,registry:Hibernate@393,registry:Host@1860,registry:Unit@1962,registry:Unit@2263,registry:Unit@351,registry:Unit@446,registry:Unit@538,registry:Unit@829,whole:Route@1868,whole:Route@1970,whole:Route@2271,whole:Route@337,whole:Route@433,whole:Route@606,whole:Route@743,whole:Route@897 d03694f9dcee767a6f42383b4cf1903286e206da11f805af2ce558bbea63133e
+internal/host/rootrestart.go rollbackUnitRestart authority B3,J1,E1 Captured canonical rollback authority is unchanged. Durable verified outcome permits later history settlement without another restart. Fixtures: TestPlan14HistoryRollbackLink and TestPlan14HistoryUnavailableCannotBlockCapturedRollback. PROPOSED E50: Root rollback/start checks retained binding before missing-row, exec, named or backing fallback; bound units cannot reach plain systemctl. file:Read@1661,registry:Hibernate@486,registry:Host@1953,registry:Unit@2055,registry:Unit@2356,registry:Unit@444,registry:Unit@539,registry:Unit@631,registry:Unit@922,whole:Route@1961,whole:Route@2063,whole:Route@2364,whole:Route@389,whole:Route@430,whole:Route@526,whole:Route@699,whole:Route@836,whole:Route@990 67115b4eaa64ab713d9fca63568805cd94df24ad15ed5d8aad2a39cc977b16c4
internal/host/rootrestart.go rootRestartChecks.live authority B3,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. file:EvalSymlinks@158 28721577488e77d229d3a5cbe6eb5806a4af0696d350a144c5ed2e4d10c36828
internal/host/rootrollback.go captureRollback authority B3,J1,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. file:Read@704,registry:Account@1191,registry:App@1227,registry:Host@1154,registry:Host@341,registry:Host@823,whole:Route@1156,whole:Route@1193,whole:Route@1229,whole:Route@343,whole:Route@825 6d3ec51488ef1be9dac2d5fe168cb38e72614242fd18064ba441273b14af6efa
internal/host/rootrollback.go loadRollback authority B3,J1,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. file:Read@451,registry:Host@1104,whole:Route@1116 6f1034337d1d03ea22a2fdd448cdd2b84dcadd455bf643086f3877c1241d1214
@@ -183,9 +183,9 @@ internal/historyreadback/readback.go RequestStatus authority E1 E17: operator sa
@coverage . dcc873ca955a35bf69ae09641d50576790123ec17aaf01637f0cfbbcf85530ce
@coverage internal/historyreadback eaca4d4caacf499db8a4c4e8044d565eec5bb42e0fc765b66f6c7559fbb04842
internal/host/claimlaunch_production.go WithProductionClaimLaunch 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 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. d207171fd9e9ac2b056f246623da6e35ae80296590ebd018852415e4cf7a7133
-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.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. PROPOSED E50: Root passes the reserved host through start and recovered-start readiness; retained binding controls the endpoint check. file:Read@2348,file:Read@2507,file:Read@707 552410ab29573f7fb7b8cc120b6238955035981e0ce23b7a824676a59b9b669c
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 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. PROPOSED E50: Root passes the retained reservation host into readiness after a recovered start; the binding still controls which endpoint can prove readiness. file:Read@2880 f1bc4c4d327750b5779ea4a4f413dd9620438e7d014202d226200fb793b595ae
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
@@ -385,7 +385,7 @@ internal/host/rootgeneration_containment_linux.go generationKernel.containedTask
internal/host/claimstop_run.go claimActivationReady authority E2 PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending. 82d893dc252c02813cd492f44dab8adc75b0e6fe5eaf4b7314c17759fdd5c83e
internal/host/rootreleaselock.go withClaimReleaseCensus authority E2 PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending. 35bcc5e18e89f9755cf1f749bbaa4cbf493380b75500183687476abd71c946b6
internal/rootparticipation/ledger.go IsAcquisitionRecovery authority E2 PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending. d71ec87152a9ba780aaf6e08fd54288bd6da40d61ad96495e7c3b29a0be200cd
-@coverage internal/instancectl 9425fb28a2251848755a36fd8e68921c1ab0f72db21320493a15e6701a76180b
+@coverage internal/instancectl 74916842a6089b3a848dbe114a8c56cb5c68d9d49b802c6533698333db76a6ba
@coverage internal/instanceexec 1a4b9c7a5c57c99ecb39b56c724f08f7e0226dc4809f31000e49e4b38cb4d059
@fields A,Account,Accounts,AddedAt,Addr,Addrs,Alias,Aliases,Alive,AllowAbsent,App,Archives,Args,At,Attempts,B,Backing,Bucket,BuildState,Callback,CallbackError,CallbackFailed,Canary,Caps,CertExpiresAt,Changed,Channel,CheckedAt,ClaimIncarnation,ClaimOwner,ClaimOwnership,ClaimRowID,ClaimStamp,Class,Contents,CreatedAt,DBPath,DNSCheckedAt,DNSStatus,Deadline,Demand,Digest,DoneBefore,Due,DueAt,Env,EnvBefore,Environment,Error,ErrorKind,ExcludeHosts,ExpectGood,ExpectOp,Expected,F,Failed,FenceGen,Fleet,FleetBox,FleetBoxes,Fleets,From,FromDB,FromOp,FromSock,Gate,GoodDemand,GoodDigest,GoodVersion,Hash,Health,Held,Hibernate,HoldOp,Host,Hosts,ID,Id,IdValue,Idle,IdleSeconds,ImportGate,Journal,Keep,Kind,Label,LastAt,LastError,LastMs,LastStatus,Limit,MaxRun,MaxRunSeconds,Method,Ms,Name,Next,NextAt,NextDue,NextGood,NextValue,Now,Number,Number64,Observed,ObservedValue,Old,On,Op,Original,Owner,Ownership,P,Path,Paths,Platform,Pool,Prefix,Prev,PrevVersion,Provisioned,RecordKey,Region,Registration,Replica,Replicas,Replication,Retirement,Route,Routes,Row,Rows,Seed,SeedID,SeedSource,ServingVersion,Slug,Socket,Spec,Sqid,State,Status,StatusNamed,Steering,StringMap,T,TValue,Tags,Target,Targets,Text,Timer,Timers,Times,To,ToDB,ToOp,ToSock,TokenHash,Tunnel,Unit,UpdateSettings,Ver,Version,W,WakeIncarnation,Wildcards,closed,conn,connect,ctx,d,dec,done,enc,full,input,mu,output,path,remote,sql,wait
internal/host/agent.go runAgent authority B,C,D,F rev3=census-ruling-seeded-instances;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with wireSeedPlacement called in the activator start's Configure step, before armMovesPass, and the startup sweep's result stored for the import rollback in Recover; read sites unchanged, offsets moved; main's B,C,D,F authority retained PROPOSED E46: captured claim selection and root journal mutation; root RPC outside service host exclusion; SQL/route data never grants root authority. Executing identity: service controller; root for inventory/retirement callbacks. file:Exec@13802,file:Open@731,registry:App@10065,whole:Route@10057,whole:Route@23109 098dab554d79d132385030a61a266947c39d91b9c2670a3be1141e7eacfbab72
@@ -434,10 +434,10 @@ 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 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_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. PROPOSED E50: Root validates optional exact predecessor definition digest in canonical configuration; no service route may authorize migration. 539b4d461ed8d25c3c61c40a26eb330dadaf7217092d0b78de56e50c7cf31f26
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_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. PROPOSED E50: Root start requires canonical shape, retained binding, exact owned fragment and effective systemd properties; missing binding selects ordinary placement only when no configured named host exists. 3e0672074eadb441f2272a1d6f42a9e58759c3146d363939bea9805090d2c32c
+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. PROPOSED E50: Root cleanup placement uses retained binding only; malformed current configuration cannot redirect cleanup or prevent an ordinary host stop. 99727f591118caf27949085ad19efa522f595f085092a7df0f47f434ae441372
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 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
@@ -445,47 +445,87 @@ internal/instancectl/claimexec.go claimExecGate.authorize authority E2 PROPOSED
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/activator/rolelaunch_root_linux.go RoleLaunch.startIsolated 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. PROPOSED E50: Agent (service UID): retain authority classification under R-E50-7 because this declaration gates work; independent final root validation does not make the agent gate a passive hint. 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_root.go type ClaimLaunchRoot 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 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. 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. PROPOSED E50: Root callback signature carries the reservation host into bound readiness; retained host binding controls readiness independently of startup configuration. 4d261ca93427b5998b7fbc7a9e4a134743053ae61c630ac58d50f6bcd31b5a3f
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/claimowners.go claimServingOwners.Select 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. PROPOSED E50: Agent (service UID): retain authority classification under R-E50-7 because this declaration gates work; independent final root validation does not make the agent gate a passive hint. 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. PROPOSED E50: Root and agent assembly select local root program reads in adopt/instance-control and authenticated typed reads in agent mode; all policy operations include independent shape refusal. file:Open@4402 e01cadfb30c3b9679f542c2caa54862839f70a749c8a535908a8b50cf94d82c4
+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. PROPOSED E50: Root and agent policy dispatch uses injected root binding readback, not route Unit/Backing, to choose program or ordinary snapshot checks. Root uses a local read; agent uses authenticated RPC. The shared dispatcher still gates installation and is authority. whole:Route@1099,whole:Route@1166,whole:Route@1705,whole:Route@1822,whole:Route@1887,whole:Route@651,whole:Route@790 f3e7d35333e1f8ceb7c9d96d628da5b2c77f55c8af85e13d634686cdcd974862
+internal/host/claimproduction.go claimServingLaunchSource 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. PROPOSED E50: Agent (service UID): retain authority classification under R-E50-7 because this declaration gates work; independent final root validation does not make the agent gate a passive hint. 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 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. 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. PROPOSED E50: Root: R-E50-7 restores the pre-E49 basis verbatim; current declaration behavior is retained. 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 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 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. PROPOSED E50: Root: R-E50-7 restores the pre-E49 basis verbatim; current declaration behavior is retained. 674c85dcb180b4818e5c6117cbed7ef7301c8094a9806d854762521d8d41d315
+internal/host/claimroleassembly.go ClaimRoleAssembly.PreflightLaunch 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. PROPOSED E50: Agent (service UID): retain authority classification under R-E50-7 because this declaration gates work; independent final root validation does not make the agent gate a passive hint. data-literal@200 5bf02fb5b6911924dbd18f063fec53552a994bcc9ae790a67db5786fa877de1e
+internal/host/claimroleassembly_linux.go ClaimRoleAssembly.Launch 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. PROPOSED E50: Agent (service UID): retain authority classification under R-E50-7 because this declaration gates work; independent final root validation does not make the agent gate a passive hint. 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 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. PROPOSED E50: Root returns ordinary inputs only without a retained binding or named candidate; malformed named configuration refuses bound input without blocking ordinary hosts. registry:Backing@462,registry:Host@294,registry:Host@385,registry:Host@630,registry:Tunnel@482,registry:Unit@445,whole:Route@640 c68aca34b07fd44193f024d99d7333dda2d0e2501a10c016496c465c017852a8
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/host/claimwakeassembly.go ClaimWakeAssembly.Build 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. PROPOSED E50: Agent (service UID): retain authority classification under R-E50-7 because this declaration gates work; independent final root validation does not make the agent gate a passive hint. 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 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. PROPOSED E50: Agent (service UID): retain authority classification under R-E50-7 because this declaration gates work; independent final root validation does not make the agent gate a passive hint. 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/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. PROPOSED E50: Root checks the exact reserved host binding and endpoint before accepting the already verified invocation; no stale startup shape list decides whether readiness applies. 1f4fec01731cdaed47c201f92164d9e0843392959ffae870f45b1434d1a982ea
+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. PROPOSED E50: Root journals explicit digest-authorized predecessor capture before stopping it and replacing only the recorded definition inode; replay never infers consent from route fields. 69be79a22720565df213dfd7dc5291333d4b9fefe10478cbf17862075f0635b1
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 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. PROPOSED E50: Root retirement uses the retained binding even when canonical config is unavailable; absence of a binding leaves ordinary typed retirement unchanged. 43beae021aa4272d21b5beb8ffb59746b1194885576c131934b9f29946029f95
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_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. PROPOSED E50: Root startup may supply a pre-resolved claim owner, rechecked against the local installation under host exclusion. A corrupt unrelated record does not block this host; canonical unit uniqueness and owned materialization remain required. f98a38666f269684126ca51e1947e81f5276b7c297a654bbca097958b6f3b5a9
+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. PROPOSED E50: Root durable materialization decode validates the migration intention, predecessor unit/invocation/cgroup identity and definition digest. file:Read@130 552a22f6d99ad0efd966645e988ad4f839ec2009060cdbd64befe23b7d908b7c
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
+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. PROPOSED E50: Root installs retained-placement stop/retirement callbacks, resumes saved retirement before binding, and isolates each configuration/program/materialization failure. Initial binding reads occur outside host exclusion then local owner is checked inside. Readiness looks up the reserved host, not a startup list. file:Read@1150 4383753c8c41977ade1e11ab3576b75c002cb25f4b4a29e36184f6f8da722164
+@coverage internal/host f1399857df0854a1c64120273f6e9dea6ba736a3102fd722c2b80c80fe263539
+
+# PROPOSED E50: root unit migration and R-E50 safety gates
+internal/caps/converge.go Converge authority E2 PROPOSED E50: Root capability producer calls CheckHost before any filesystem or systemd effect. 7231c4e38b0093565412265a540af518b60fa8b0f0263502ce8845b2d7c2967d
+internal/caps/converge.go Restart authority E2 PROPOSED E50: Root capability restart calls CheckHost before systemd. 4e0bf2a8e6e6f819974b741b2d11ad5ab3b24afe0b7dd2c94b1e740faf4b6e50
+internal/caps/converge.go Restore authority E2 PROPOSED E50: Root capability rollback calls CheckHost before restoring a drop-in or restarting. cf8fe4fd012530c45b28cc61653edba94021efe99e1fe49b1d1b91767f08e337
+internal/caps/converge.go type Ops authority E2 schema-only PROPOSED E50: Root capability callback includes an independent pre-effect host gate used by census-bound operations; unrelated callers keep nil semantics. file:Query@322 c13796aea72fe9cf11991e2426f22254d488e25f1d3e2b06da362bb06962e3ee
+internal/host/caps_pass.go capabilityPass authority E4,J1,J4 root-route PROPOSED E50: Root capability pass consumes its caller-installed CheckHost callback. Production adoption installs the census-bound refusal on both convergence paths; fixture/legacy nil callbacks keep prior semantics. whole:Route@2320,whole:Route@596,whole:Route@657 00565b36d74d8700ca17d9b190e49db161b98117e7d98249012901a6f1aec7de
+internal/host/claimunit_effective.go claimEffectiveUnit authority E2 PROPOSED E50: Root checks loaded fragment, all drop-ins, ExecStart, UID/GID, network and sandbox properties. Only exact root-generated reservation bytes in the private runtime directory are permitted. file:ReadAll@2269 7d6f505c8ea12a2df461f20f2a75301b6933d734f6adb2a47f6af1e966e014c2
+internal/host/claimunit_effective.go claimIPSet authority E2 PROPOSED E50: Root normalizes systemd IP prefixes for exact network-policy comparison; malformed prefixes refuse. afa75b50fae4f7d0a785fc2fcfc03f27b68b4accbc8bcf6b1404984a7df282fc
+internal/host/claimunit_effective.go claimReservedDropin authority E2 PROPOSED E50: Root constructs exact expected reservation drop-in bytes from the retained host identity and fixed private paths; a caller cannot choose bind destinations. data-literal@253 759da720bdcb3a0e3f0b6b80325653d6704a56b3187871c5e763b1a2040f670e
+internal/host/claimunit_migrate.go claimPredecessorPlacement authority E2 PROPOSED E50: Root accepts only the exact system.slice service path or systemd default instantiated-service slice for the canonical unit. 7c2248d3c2a76d2c46d813ab960dbc80095db5348a21bd1fc709633810a4be4a
+internal/host/claimunit_migrate.go claimUnitMaterializer.finishMigration authority E2 PROPOSED E50: Root resumes durable migration; exact definition identity gates replacement and the complete predecessor cgroup must be empty before materialization completes. 928197b15114af72563e60f1e9661c5f33798b5bcfc31b6f8d5d85860cfe4e6c
+internal/host/claimunit_migrate.go claimUnitMaterializer.migration authority E2 PROPOSED E50: Root accepts an existing operator definition only with canonical digest consent and independently captured systemd invocation plus cgroup inode. f40694cd913fb9db331ec82e81209f120c2cd115ecbd4e1c0947f2a6c1973500
+internal/host/claimunit_migrate.go claimUnitMigration.valid authority E2 PROPOSED E50: Root decoded migration must match its binding digest, definition identity and independently retained predecessor placement. e94154551a8a463be04b2f0591c01336cf9a2c18c6ac2211d1a38701844029e7
+internal/host/claimunit_migrate.go claimUnitPredecessor.valid authority E2 PROPOSED E50: Root validates retained predecessor unit, invocation and cgroup identity before a stop can be attempted. 6216b37e15df78ff7ec405158c9956013cf0db7318440dba500bc5f7f0025094
+internal/host/claimunit_migrate_kernel.go claimPredecessorKernel.capture authority E2 PROPOSED E50: Root brackets the complete predecessor cgroup identity/population read with matching systemd observations; foreign fragment or drop-in refuses capture. 424fb0bf16467b900b87aa708784ce0bca96c06178033d72acaf550176894501
+internal/host/claimunit_migrate_kernel.go claimPredecessorKernel.show authority E2 PROPOSED E50: Root reads systemd state, invocation, group, fragment and drop-ins; duplicate or missing properties refuse migration. 6732081d7f5dac8d9791850eeda7290ab8c8c7eb9a8ad68cc611d765ee9e8859
+internal/host/claimunit_migrate_kernel.go claimPredecessorKernel.stop authority E2 PROPOSED E50: Root rechecks retained invocation and cgroup inode, stops once, and requires inactive state plus the whole cgroup empty; lost replies retry without accepting a replacement. bf56fc2b01a7fe0bb411e192b85b783c6fcc27856ee47e3c2590ae427100cec6
+internal/host/claimunit_migrate_linux.go claimPredecessorGroup authority E2 PROPOSED E50: Root opens the exact no-follow cgroup on verified cgroup2 and reads bounded cgroup.events; population and inode are kernel evidence, not registry authority. file:Fstatfs@434,file:Openat@688,file:Read@928 a654799005a2027ecbcc2da5eea5e12f5f651c63b2c2eaf304f88fb9c49dbc97
+internal/host/claimunit_migrate_other.go claimPredecessorGroup authority E2 PROPOSED E50: Root non-Linux predecessor probe refuses; it cannot supply kernel stop authority. 242ad27ad3ef9d094e21816686ead9ba2a31e16f68ac611c11c833b7d20d727f
+internal/host/claimunit_policy.go checkClaimUnitShape authority E2 PROPOSED E50: Root checks candidate shape against retained canonical binding and exact installation owner; removal or mutation of Unit/Backing cannot switch authority paths. registry:Backing@288,registry:Host@172,registry:Host@247,registry:Host@473,registry:Tunnel@308,registry:Unit@271,whole:Route@506 4eb217cb198109f864078577d9875679fa5f5e1b724eb80453c6098d3c6a99d7
+internal/host/claimunit_policy.go claimBoundCapsRefusal authority E2 PROPOSED E50: Root denies capability writes and restarts for configured or retained bound hosts, including when current configuration is corrupt or removed. 83ec5fc9295b522840f478e5e1e4634025c1584bf4ad3b3d62436eccca10342a
+internal/host/claimunit_policy.go claimUnitShapePolicy authority E2 PROPOSED E50: Root/agent policy uses a registry candidate only to request independent binding validation; a missing or wrong host refuses every operation. registry:Host@374,whole:Route@361,whole:Route@447 ee4eecf8d18a8038018284f6fd7c95b4b0e1df6cfb0ccc600d92b280a01f68f9
+internal/host/claimunit_policy.go productionClaimCapsOps authority E2 PROPOSED E50: Root wraps capability operations with a census-only bound-host refusal and retains the pre-existing callback; no-census effects are unchanged. 1e8c9ab5761b6ca71f9e1185537244792fe4eb38e0bb7c63f487f1a00c0f52f5
+internal/host/claimunit_policy.go productionClaimUnitShapePolicy authority E2 PROPOSED E50: Root modes use local root state, agent mode uses authenticated typed readback; neither treats registry shape as binding authority. whole:Route@321,whole:Route@460 e0b0cffd4a805c5a88bd163da7adceeee75e403ebd7cda5e3979a3315f623750
+internal/host/claimunit_policy.go rootClaimRestartShape authority E2 PROPOSED E50: Root refuses all retained bound units before ordinary restart/start/rollback effects; a supported reserved always-running restart remains pending. registry:Host@147,whole:Route@107,whole:Route@227,whole:Route@93 1d0370516c32482b1784f4bd4ab0deb042c31120d81f4dc8c417f52b24ef76ac
+internal/host/claimunit_policy.go rootClaimUnitInputs authority E2 PROPOSED E50: Root returns program args/env only after validating local retained binding and root-owned program digest. whole:Route@169 dccb151d99859b6adfe166b75663817e2ce4caa30dd0a0bf052d3126eb41e185
+internal/host/claimunit_policy.go rootRestartChecks.checkUnitShape authority E2 PROPOSED E50: Root gates shape refusal on census presence; absent census returns to unchanged legacy effects. whole:Route@270 e9f3a7f3ee37e5c0fa4fb63544ea59c054570a679ccf9388ae812753e6cbdb58
+internal/host/claimunit_policy.go var openClaimUnitBindingsRead authority E2 PROPOSED E50: Root opens the fixed root-owned binding store read-only; service-writable registry data is never the source of binding authority. file:Open@74 3fde76a065dbe010c976829f1543e9c1e2430b8890fde5381579040d027686b5
+internal/host/claimunit_policy.go withClaimUnitShape authority E2 PROPOSED E50: Root reads binding and canonical configuration locally. Corrupt named configuration can deny bound hosts but cannot deny an ordinary unbound host. registry:Backing@274,registry:Backing@574,registry:Host@320,registry:Host@510,registry:Host@783,registry:Tunnel@294,registry:Tunnel@594,registry:Unit@257,registry:Unit@557,whole:Route@724 ea6983f666db3b00eba85b07628871ea745adb3e086d06e9ed4ccfcca3ead61a
+internal/host/rootrestart.go rootRestartRouteWith authority B3 rev3=merge-ruling-6;basis=Select main stamped-unit effects before binding dispatch without a census; ordinary routes still bypass census and record reads. PROPOSED E50: Root restart checks retained shape before early returns or backing fallback; cleared Unit/Backing cannot evade the check. registry:Unit@467,registry:Unit@533,whole:Route@1091,whole:Route@389,whole:Route@454,whole:Route@628,whole:Route@821,whole:Route@931 ae5f9f3f1d23a928c5d2fbf72c3f2acf1d821efb82859547c4d9db998fca3bb8
+internal/instancectl/claimunit_bound_stop.go ClaimUnits.StopInvocation authority E2 PROPOSED E50: Root/service-control performs a post-stop invocation and group check; replacement races return uncertainty, never a successful exact-stop result. 6a577fbb72bcf8ee8950352e28eb08f8e12c1ff4412b913c00b93a3d3957dfd9
+@coverage internal/caps 420ba19b80876c10dd3c448bdb7eef4092274cb4a1a870fb3abdf3d1f29b4806
+
+# PROPOSED E50: root unit migration and R-E50 safety gates
+internal/host/claimunit_effective.go claimEffectiveRefusal.Error diagnostic E2 PROPOSED E50: Root diagnostic preserves the existing public permission error while retaining an internal property name for tests. 5f95a2305ffb1fe7da5a4f0fb2d497d0032e7d60c31b0d84bdbf9642d31654ca
+internal/host/claimunit_effective.go claimEffectiveRefusal.Unwrap diagnostic E2 PROPOSED E50: Root diagnostic exposes the underlying refusal to errors.Is; it grants no operation authority. 8678c5f6cd504036763f2fb1617f64cbe1c696cfac6bf71d40e0c3a459170a43
+
+# PROPOSED E50: root unit migration and R-E50 safety gates
+internal/host/claimunit_dropin_linux.go openClaimUnitDropin authority E2 PROPOSED E50: Root opens the validated unit name beneath checked /run/systemd/system using no-follow descriptors; the unit directory and regular single-link file must be root-owned and not group/world-writable. Instantiated @ names are accepted without relaxing trust. file:Fstat@594,file:Fstat@884,file:Open@190,file:Openat@396 bd769502743c400fa31ee7b1cf125d52fc03f7dd6f5bd99e3195c2e60be95cb9
+internal/host/claimunit_dropin_other.go openClaimUnitDropin authority E2 PROPOSED E50: Root opens the validated unit name beneath checked /run/systemd/system using no-follow descriptors; the unit directory and regular single-link file must be root-owned and not group/world-writable. Instantiated @ names are accepted without relaxing trust. bb3a29d1ad8840139f7236971ecb2bae93e6323007f2fdd625114bb3ff105397