carlos / platform Public

Claim activation tools, run 6: activation invariants (not for merge)

offline-begin refuses until update and recovery state is settled; restore closes only after a bounded verify pass confirms every recorded unit, trigger and file; activation-firing timers start after close; property witness over interruptions on production units.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 310e4ca89fe5e8c7bee22050e32d864fae670c7e parent a93395a
12 files changed, +920 −100
  • internal/host/activation_adoption_linux.go +1 −1
  • internal/host/activation_command_linux.go +4 −0
  • internal/host/activation_invariants_rootrunner_linux_test.go +248 −0
  • internal/host/activation_matrix_rootrunner_linux_test.go +292 −0
  • internal/host/activation_offline_linux.go +195 −69
  • internal/host/activation_offline_rootrunner_linux_test.go +2 −2
  • internal/host/activation_production_rootrunner_linux_test.go +5 −3
  • internal/host/activation_restore_order_rootrunner_linux_test.go +2 −2
  • internal/host/activation_settled_linux.go +132 −0
  • internal/host/systemapply.go +3 −0
  • internal/host/systemunits.go +5 −0
  • internal/provision/testdata/plan14-root-reads-activation-tools.tsv +31 −23

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.012s
?   	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	325.811s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.277s
ok  	github.com/carlosframework/platform/cmd/moveprobe	93.079s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.740s
ok  	github.com/carlosframework/platform/internal/activator	251.013s
ok  	github.com/carlosframework/platform/internal/agentsock	0.068s
ok  	github.com/carlosframework/platform/internal/alerting	0.007s
ok  	github.com/carlosframework/platform/internal/analytics	0.024s
ok  	github.com/carlosframework/platform/internal/appconfig	2.757s
ok  	github.com/carlosframework/platform/internal/apperr	0.131s
ok  	github.com/carlosframework/platform/internal/applog	0.333s
ok  	github.com/carlosframework/platform/internal/awsinv	0.008s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.009s
ok  	github.com/carlosframework/platform/internal/boxid	0.800s
?   	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.032s
ok  	github.com/carlosframework/platform/internal/caps	0.032s
ok  	github.com/carlosframework/platform/internal/certwarm	0.008s
ok  	github.com/carlosframework/platform/internal/cgroup	0.732s
ok  	github.com/carlosframework/platform/internal/changelog	0.577s
ok  	github.com/carlosframework/platform/internal/claimactivation	0.066s
ok  	github.com/carlosframework/platform/internal/clamd	0.074s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	402.890s
?   	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.764s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.023s
ok  	github.com/carlosframework/platform/internal/dbverify	0.007s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.006s
ok  	github.com/carlosframework/platform/internal/docsite	0.016s
ok  	github.com/carlosframework/platform/internal/domains	2.701s
ok  	github.com/carlosframework/platform/internal/doorkey	0.009s
ok  	github.com/carlosframework/platform/internal/economics	0.009s
ok  	github.com/carlosframework/platform/internal/edge	28.336s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.014s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.305s
ok  	github.com/carlosframework/platform/internal/gate	1.845s
ok  	github.com/carlosframework/platform/internal/genselect	0.039s
ok  	github.com/carlosframework/platform/internal/geo	0.022s
ok  	github.com/carlosframework/platform/internal/geoip	0.054s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.300s
ok  	github.com/carlosframework/platform/internal/host	181.522s
ok  	github.com/carlosframework/platform/internal/hostactors	0.803s
ok  	github.com/carlosframework/platform/internal/hostbox	4.017s
ok  	github.com/carlosframework/platform/internal/hostclaim	1.308s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.006s
ok  	github.com/carlosframework/platform/internal/hostexec	7.649s
ok  	github.com/carlosframework/platform/internal/hostfence	0.113s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.950s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.060s
ok  	github.com/carlosframework/platform/internal/hostguard	1.088s
ok  	github.com/carlosframework/platform/internal/hostops	2.792s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.003s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.007s
ok  	github.com/carlosframework/platform/internal/hostrelocate	1.120s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.421s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.004s
ok  	github.com/carlosframework/platform/internal/infraview	0.003s
ok  	github.com/carlosframework/platform/internal/instancectl	5.271s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.932s
ok  	github.com/carlosframework/platform/internal/instancefs	0.029s
ok  	github.com/carlosframework/platform/internal/instancekey	0.003s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.007s
ok  	github.com/carlosframework/platform/internal/layout	2.382s
ok  	github.com/carlosframework/platform/internal/lease	10.072s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.023s
ok  	github.com/carlosframework/platform/internal/ledger	0.051s
ok  	github.com/carlosframework/platform/internal/mail	0.364s
ok  	github.com/carlosframework/platform/internal/maintenance	0.149s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.787s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.216s
ok  	github.com/carlosframework/platform/internal/metrics	0.012s
ok  	github.com/carlosframework/platform/internal/moves	24.270s
ok  	github.com/carlosframework/platform/internal/moves/archive	1.470s
ok  	github.com/carlosframework/platform/internal/objstore	0.454s
ok  	github.com/carlosframework/platform/internal/ops	0.342s
ok  	github.com/carlosframework/platform/internal/park	0.331s
ok  	github.com/carlosframework/platform/internal/privatefile	0.004s
ok  	github.com/carlosframework/platform/internal/provision	39.732s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.010s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	6.577s
ok  	github.com/carlosframework/platform/internal/relay	7.321s
ok  	github.com/carlosframework/platform/internal/release	25.688s
ok  	github.com/carlosframework/platform/internal/rollout	4.576s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.005s
ok  	github.com/carlosframework/platform/internal/rootaws	0.010s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.065s
ok  	github.com/carlosframework/platform/internal/rootstate	0.042s
ok  	github.com/carlosframework/platform/internal/runs	0.657s
ok  	github.com/carlosframework/platform/internal/s3compat	0.009s
ok  	github.com/carlosframework/platform/internal/safefile	0.039s
ok  	github.com/carlosframework/platform/internal/schedule	3.142s
ok  	github.com/carlosframework/platform/internal/seeds	0.025s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	22.450s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.624s
ok  	github.com/carlosframework/platform/internal/status	0.046s
ok  	github.com/carlosframework/platform/internal/steering	0.009s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.748s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.012s
ok  	github.com/carlosframework/platform/internal/termfence	0.009s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.014s
ok  	github.com/carlosframework/platform/internal/updatewords	0.006s
ok  	github.com/carlosframework/platform/internal/usage	0.006s
ok  	github.com/carlosframework/platform/internal/version	0.005s
ok  	github.com/carlosframework/platform/internal/vet	1.798s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 48693410 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.03s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.02s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.032s
=== RUN   TestT4StorageProbeMinio
2026/10/16 20:17:36 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DF1BE714EA0EA4, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.026s
minio contract: PASS
diff --git a/internal/host/activation_adoption_linux.go b/internal/host/activation_adoption_linux.go
index e41f9708c..5dfd18ec8 100644
--- a/internal/host/activation_adoption_linux.go
+++ b/internal/host/activation_adoption_linux.go
@@ -35,7 +35,7 @@ func activationTransactionAllowed(etc string, recovery bool) error {
if err != nil {
return err
}
- if r.Phase != "done" && !(recovery && r.Phase == "complete") {
+ if r.Phase != "done" {
return errActivation
}
return nil
diff --git a/internal/host/activation_command_linux.go b/internal/host/activation_command_linux.go
index ae72accb5..b0dd5854b 100644
--- a/internal/host/activation_command_linux.go
+++ b/internal/host/activation_command_linux.go
@@ -260,6 +260,10 @@ func runSystemClaims(args []string, out io.Writer) error {
case "offline-begin", "offline-restore", "offline-seed":
tx := activationOffline{units: "/etc/systemd/system", state: "/etc/carlos/claim-activation/offline", systemctl: claimSystemctl, drained: activationProcessesDrained}
var err error
+ tx.updateRoot, err = activationConfiguredUpdateRoot("/etc/carlos/host.env", os.Getenv("CARLOS_OPT_DIR"))
+ if err != nil {
+ return err
+ }
if args[0] == "offline-seed" {
seedCtx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer cancel()
diff --git a/internal/host/activation_invariants_rootrunner_linux_test.go b/internal/host/activation_invariants_rootrunner_linux_test.go
new file mode 100644
index 000000000..007ca683c
--- /dev/null
+++ b/internal/host/activation_invariants_rootrunner_linux_test.go
@@ -0,0 +1,248 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestRootActivationSettledPrecondition(t *testing.T) {
+ for _, name := range []string{"journal.json", "staging", "rearm-pending.json", "../guard/update-begun.json"} {
+ t.Run(name, func(t *testing.T) {
+ tx, d := offlineFixture(t)
+ tx.updateRoot = filepath.Join(d, "system/router")
+ path := filepath.Join(tx.updateRoot, name)
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("pending"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ for _, dry := range []bool{true, false} {
+ if err := tx.begin(t.Context(), dry); err == nil || !strings.Contains(err.Error(), "settle the update first") {
+ t.Fatal("admitted pending update", dry, err)
+ }
+ }
+ if _, err := os.Stat(tx.state); !os.IsNotExist(err) {
+ t.Fatal("refusal created transaction", err)
+ }
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ if err := tx.begin(t.Context(), false); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("pending"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ for _, operation := range []func(context.Context, bool) error{tx.begin, tx.restore} {
+ if err := operation(t.Context(), false); err == nil || !strings.Contains(err.Error(), "settle the update first") {
+ t.Fatal("resume admitted pending update", err)
+ }
+ }
+ })
+ }
+}
+
+func TestRootActivationCloseInvariant(t *testing.T) {
+ for _, scenario := range []string{"one-drift", "persistent-drift", "foreign-inode"} {
+ t.Run(scenario, func(t *testing.T) {
+ tx, _ := offlineFixture(t)
+ base := tx.systemctl
+ failed := false
+ tx.systemctl = func(ctx context.Context, args ...string) ([]byte, error) {
+ if args[0] == "reset-failed" && args[1] == "carlos-adopt.service" {
+ failed = false
+ }
+ raw, err := base(ctx, args...)
+ if args[0] == "show" && args[1] == "carlos-adopt.service" && failed {
+ raw = []byte(strings.ReplaceAll(string(raw), "ActiveState=inactive", "ActiveState=failed"))
+ }
+ return raw, err
+ }
+ if err := tx.begin(t.Context(), false); err != nil {
+ t.Fatal(err)
+ }
+ passes := 0
+ tx.checkpoint = func(point string) error {
+ if point == "verify-close" {
+ passes++
+ if scenario == "persistent-drift" || passes == 1 {
+ failed = true
+ }
+ if scenario == "foreign-inode" && passes == 1 {
+ p := filepath.Join(tx.units, "carlos-adopt.service")
+ raw, err := os.ReadFile(p)
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(p+".foreign", raw, 0644); err != nil {
+ return err
+ }
+ return os.Rename(p+".foreign", p)
+ }
+ }
+ return nil
+ }
+ err := tx.restore(t.Context(), false)
+ if scenario == "one-drift" {
+ if err != nil || passes != 2 || offlineRecord(t, tx).Phase != "done" {
+ t.Fatal("did not reconcile and verify again", passes, err)
+ }
+ } else {
+ if err == nil || !strings.Contains(err.Error(), "carlos-adopt.service") || offlineRecord(t, tx).Phase != "complete" {
+ t.Fatal("closed on mismatch", passes, err)
+ }
+ if target, _ := os.Readlink(filepath.Join(tx.units, activationEdgeUnit)); target != "/dev/null" {
+ t.Fatal("released edge")
+ }
+ }
+ })
+ }
+}
+
+func TestRootActivationTriggerAfterClose(t *testing.T) {
+ tx, _ := offlineFixture(t)
+ base := tx.systemctl
+ active := true
+ tx.systemctl = func(ctx context.Context, args ...string) ([]byte, error) {
+ if args[1%len(args)] == "carlos-adopt.timer" {
+ switch args[0] {
+ case "start":
+ if offlineRecord(t, tx).Phase != "done" {
+ return nil, errors.New("timer started before done")
+ }
+ active = true
+ case "stop":
+ active = false
+ }
+ }
+ raw, err := base(ctx, args...)
+ if args[0] == "show" && args[1] == "carlos-adopt.timer" && active {
+ raw = []byte(strings.ReplaceAll(string(raw), "ActiveState=inactive", "ActiveState=active"))
+ }
+ return raw, err
+ }
+ if err := tx.begin(t.Context(), false); err != nil {
+ t.Fatal(err)
+ }
+ if err := tx.restore(t.Context(), false); err != nil {
+ t.Fatal(err)
+ }
+ if !active {
+ t.Fatal("active timer not restored")
+ }
+}
+
+func TestRootActivationUpdateInProgress(t *testing.T) {
+ tx, _ := offlineFixture(t)
+ release, err := tx.settled(t.Context(), false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := tx.begin(t.Context(), false); err == nil || !strings.Contains(err.Error(), "settle the update first") {
+ t.Fatal("update lock admitted", err)
+ }
+ release()
+ base := tx.systemctl
+ tx.systemctl = func(ctx context.Context, args ...string) ([]byte, error) {
+ if args[0] == "show" && args[1] == "carlos-system-update.service" {
+ return []byte("LoadState=loaded\nActiveState=activating\n"), nil
+ }
+ return base(ctx, args...)
+ }
+ if err := tx.begin(t.Context(), false); err == nil || !strings.Contains(err.Error(), "settle the update first") {
+ t.Fatal("activating update admitted", err)
+ }
+}
+
+func TestRootActivationProductionCloseRefusesInode(t *testing.T) {
+ tx := setupActivationProduction(t)
+ defer cleanupActivationProduction(t, tx)
+ if err := tx.begin(t.Context(), false); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(tx.units, "carlos-stage.path")
+ changed := false
+ tx.checkpoint = func(point string) error {
+ if point == "verify-close" && !changed {
+ changed = true
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(path+".foreign", raw, 0644); err != nil {
+ return err
+ }
+ return os.Rename(path+".foreign", path)
+ }
+ return nil
+ }
+ err := tx.restore(t.Context(), false)
+ if err == nil || !strings.Contains(err.Error(), "carlos-stage.path: recorded file inode") {
+ t.Fatal("no exact inode refusal", err)
+ }
+ if r := offlineRecord(t, tx); r.Phase != "complete" {
+ t.Fatal("closed on foreign file", r.Phase)
+ }
+ if load, _, err := tx.status(t.Context(), activationEdgeUnit); err != nil || load != "masked" {
+ t.Fatal("edge released", load, err)
+ }
+ if sameActivationFile(path, filepath.Join(tx.state, "carlos-stage.path.original")) {
+ t.Fatal("overwrote foreign inode")
+ }
+}
+
+func TestRootActivationRecordedStates(t *testing.T) {
+ for _, index := range []int{0, len(activationTriggers)} {
+ for _, item := range []activationOfflineItem{
+ {LoadState: "loaded", ActiveState: "failed"},
+ {LoadState: "masked", ActiveState: "active", Masked: true, Active: true},
+ } {
+ tx, _ := offlineFixture(t)
+ if err := tx.begin(t.Context(), false); err != nil {
+ t.Fatal(err)
+ }
+ r := offlineRecord(t, tx)
+ r.Items[index].LoadState = item.LoadState
+ r.Items[index].ActiveState = item.ActiveState
+ r.Items[index].Masked = item.Masked
+ r.Items[index].Active = item.Active
+ if differences := tx.closeDifferences(t.Context(), r); !strings.Contains(strings.Join(differences, "\n"), "want load="+item.LoadState+" active="+item.ActiveState) {
+ t.Fatal("lost recorded state", index, differences)
+ }
+ }
+ }
+}
+
+func TestRootActivationConfiguredUpdateRoot(t *testing.T) {
+ tx, d := offlineFixture(t)
+ config := filepath.Join(d, "host.env")
+ custom := filepath.Join(d, "custom-opt")
+ if err := os.WriteFile(config, []byte("CARLOS_OPT_DIR="+custom+"\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ root, err := activationConfiguredUpdateRoot(config, "")
+ if err != nil || root != systemRootDir(custom) {
+ t.Fatal("wrong configured update root", root, err)
+ }
+ if _, err := activationConfiguredUpdateRoot(config, "/opt/carlos"); err == nil {
+ t.Fatal("mismatched operator override accepted")
+ }
+ tx.updateRoot = root
+ activationProductionWrite(t, filepath.Join(root, "journal.json"), []byte("pending"), 0600)
+ if err := tx.begin(t.Context(), false); err == nil || !strings.Contains(err.Error(), filepath.Join(root, "journal.json")) {
+ t.Fatal("configured pending journal missed", err)
+ }
+ if err := os.WriteFile(config, []byte("CARLOS_OPT_DIR=relative\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := activationConfiguredUpdateRoot(config, ""); err == nil {
+ t.Fatal("relative root accepted")
+ }
+}
diff --git a/internal/host/activation_matrix_rootrunner_linux_test.go b/internal/host/activation_matrix_rootrunner_linux_test.go
new file mode 100644
index 000000000..608199fae
--- /dev/null
+++ b/internal/host/activation_matrix_rootrunner_linux_test.go
@@ -0,0 +1,292 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+const activationMatrixDir = "/var/lib/carlos-activation-matrix"
+
+type activationMatrixCursor struct {
+ Cut int
+ Timer bool
+ Resume bool
+ Boot string
+ Point string
+ Pending bool
+}
+
+func activationMatrixTransaction() activationOffline {
+ return activationOffline{units: "/etc/systemd/system", state: "/etc/carlos/claim-activation/offline", systemctl: offlineRealSystemctl, drained: quiesceLegacyRootMutators}
+}
+
+func activationMatrixPending(t *testing.T, tx activationOffline, operation func(context.Context, bool) error) {
+ t.Helper()
+ path := filepath.Join(tx.updateDir(), "journal.json")
+ if _, err := os.Lstat(path); !os.IsNotExist(err) {
+ t.Fatal("pre-existing update journal", err)
+ }
+ // A valid interrupted install, with the fixture's real previous executable.
+ raw, err := json.Marshal(systemJournal{Phase: systemPhaseInstalled, Target: "v2.0.0", PrevBuild: "v1.0.0", StartedAt: time.Now()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ activationProductionWrite(t, path, raw, 0600)
+ activationMatrixRefusePending(t, tx, operation)
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func activationMatrixRefusePending(t *testing.T, tx activationOffline, operation func(context.Context, bool) error) {
+ t.Helper()
+ before, _ := os.ReadFile(filepath.Join(tx.state, "transaction.json"))
+ err := operation(t.Context(), false)
+ if err == nil || !strings.Contains(err.Error(), "settle the update first") {
+ t.Fatal("pending update admitted", err)
+ }
+ after, _ := os.ReadFile(filepath.Join(tx.state, "transaction.json"))
+ if !bytes.Equal(before, after) {
+ t.Fatal("refusal changed transaction")
+ }
+}
+
+func activationMatrixBaseline(t *testing.T, tx activationOffline, timer bool) {
+ t.Helper()
+ servingRunnerSystemctl(t, "stop", activationEdgeUnit, "carlos-adopt.timer")
+ servingRunnerSystemctl(t, append([]string{"stop"}, rootMigrationOfflineUnits...)...)
+ servingRunnerSystemctl(t, "reset-failed")
+ if err := os.RemoveAll(tx.state); err != nil {
+ t.Fatal(err)
+ }
+ if timer {
+ servingRunnerSystemctl(t, "start", "carlos-adopt.timer")
+ // The production OnBootSec timer can run immediately. Wait for the
+ // real adoption process to settle before recording the baseline.
+ for n := 0; n < 100; n++ {
+ raw, err := tx.systemctl(t.Context(), "show", "carlos-adopt.service", "--property=ActiveState", "--value")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.TrimSpace(string(raw)) != "activating" {
+ break
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ servingRunnerSystemctl(t, "stop", "carlos-adopt.service")
+ servingRunnerSystemctl(t, "reset-failed", "carlos-adopt.service")
+ }
+ activationMatrixPending(t, tx, tx.begin)
+ if err := tx.begin(t.Context(), false); err != nil {
+ t.Fatal(err)
+ }
+ r := offlineRecord(t, tx)
+ if r.Items[0].Active != timer {
+ t.Fatal("wrong timer baseline", r.Items[0])
+ }
+}
+
+func activationMatrixCheck(t *testing.T, tx activationOffline, point string) {
+ t.Helper()
+ r := offlineRecord(t, tx)
+ if r.Phase != "done" {
+ if target, _ := os.Readlink(filepath.Join(tx.units, activationEdgeUnit)); target != "/dev/null" {
+ t.Fatal("edge released while open", point, r.Phase)
+ }
+ for _, item := range r.Items {
+ if err := tx.checkItem(item); err != nil {
+ t.Fatal("foreign inode while open", point, item.Name, err)
+ }
+ }
+ }
+ if point == "journal-done" && r.EdgePending {
+ if differences := tx.closeDifferences(t.Context(), r); len(differences) != 0 {
+ t.Fatal("closed on mismatch", differences)
+ }
+ }
+ if strings.HasPrefix(point, "start-") && strings.HasSuffix(point, ".timer") && r.Phase != "done" {
+ t.Fatal("timer activated before closure")
+ }
+}
+
+func activationMatrixInterrupt(t *testing.T, tx activationOffline, cut int) (string, bool) {
+ t.Helper()
+ n := 0
+ point := ""
+ stop := errors.New("matrix interruption")
+ tx.checkpoint = func(name string) error {
+ activationMatrixCheck(t, tx, name)
+ n++
+ if n == cut {
+ point = name
+ return stop
+ }
+ return nil
+ }
+ err := tx.restore(t.Context(), false)
+ if err != nil && !errors.Is(err, stop) {
+ t.Fatal(cut, point, err)
+ }
+ return point, errors.Is(err, stop)
+}
+
+func activationMatrixResume(t *testing.T, tx activationOffline, point string) {
+ t.Helper()
+ before := offlineRecord(t, tx)
+ if before.Phase != "done" {
+ activationMatrixPending(t, tx, tx.restore)
+ }
+ tx.checkpoint = func(name string) error { activationMatrixCheck(t, tx, name); return nil }
+ deadline := time.Now().Add(20 * time.Second)
+ for {
+ saved, err := os.ReadFile(filepath.Join(tx.state, "transaction.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = tx.restore(t.Context(), false)
+ if err == nil {
+ break
+ }
+ if !strings.HasPrefix(err.Error(), "claim activation: settle the update first:") || time.Now().After(deadline) {
+ t.Fatal("resume", point, err)
+ }
+ after, e := os.ReadFile(filepath.Join(tx.state, "transaction.json"))
+ if e != nil || !bytes.Equal(saved, after) {
+ t.Fatal("busy recovery refusal changed transaction", e)
+ }
+ t.Log("boot recovery still settling; unchanged refusal", err)
+ time.Sleep(50 * time.Millisecond)
+ }
+ r := offlineRecord(t, tx)
+ if r.Phase != "done" || r.EdgePending || r.TriggersPending {
+ t.Fatal("unfinished continuation", r.Phase, r.EdgePending, r.TriggersPending)
+ }
+ // Once done is durable, timer-driven adoption is ordinary operation. The
+ // close checkpoint, not a later live service observation, checks equality.
+ for _, item := range r.Items[:len(activationTriggers)] {
+ if item.Active {
+ _, active, err := tx.status(t.Context(), item.Name)
+ if err != nil || active != "active" {
+ t.Fatal("active trigger not restored", item.Name, active, err)
+ }
+ }
+ }
+}
+
+func TestRootActivationProductionInvariantMatrix(t *testing.T) {
+ tx := setupActivationProduction(t)
+ defer cleanupActivationProduction(t, tx)
+ activationMatrixTimer(t)
+ for _, timer := range []bool{false, true} {
+ complete := false
+ for cut := 1; cut <= 120; cut++ {
+ activationMatrixBaseline(t, tx, timer)
+ point, interrupted := activationMatrixInterrupt(t, tx, cut)
+ activationMatrixResume(t, tx, point)
+ t.Logf("matrix reboot=false timer=%t pending={present:refused,absent:restored} cut=%d point=%s", timer, cut, point)
+ if !interrupted {
+ complete = true
+ break
+ }
+ }
+ if !complete {
+ t.Fatal("matrix did not enumerate all boundaries")
+ }
+ }
+}
+
+// The designated runner invokes this test from a persistent boot unit. Each
+// successful invocation prepares exactly one actual reboot; cursor and boot ID
+// are durable. Pending admission is a refused branch, so it has no checkpoints.
+func TestRootActivationProductionInvariantBootStep(t *testing.T) {
+ var c activationMatrixCursor
+ cursor := activationMatrixDir + "/cursor.json"
+ raw, err := os.ReadFile(cursor)
+ tx := activationMatrixTransaction()
+ if os.IsNotExist(err) {
+ tx = setupActivationProduction(t)
+ activationMatrixTimer(t)
+ c.Cut = 1
+ } else if err != nil || json.Unmarshal(raw, &c) != nil {
+ t.Fatal("cursor", err)
+ }
+ boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.Resume {
+ if c.Boot == string(boot) {
+ t.Fatal("no actual reboot")
+ }
+ activationMatrixCheck(t, tx, "boot-resume")
+ if c.Pending {
+ activationMatrixRefusePending(t, tx, tx.restore)
+ if err := os.Remove(filepath.Join(tx.updateDir(), "journal.json")); err != nil {
+ t.Fatal(err)
+ }
+ }
+ activationMatrixResume(t, tx, c.Point)
+ t.Logf("matrix reboot=true timer=%t pending={present:refused,absent:restored} cut=%d point=%s boot_pending=%t boot=%s", c.Timer, c.Cut, c.Point, c.Pending, strings.TrimSpace(string(boot)))
+ c.Cut++
+ }
+ if c.Cut > 120 {
+ t.Fatal("unbounded checkpoint matrix")
+ }
+ for {
+ activationMatrixBaseline(t, tx, c.Timer)
+ point, interrupted := activationMatrixInterrupt(t, tx, c.Cut)
+ if !interrupted {
+ activationMatrixResume(t, tx, point)
+ if c.Timer {
+ cleanupActivationProduction(t, tx)
+ activationProductionWrite(t, activationMatrixDir+"/control", []byte("done\n"), 0600)
+ return
+ }
+ c.Timer = true
+ c.Cut = 1
+ continue
+ }
+ c.Pending = offlineRecord(t, tx).Phase != "done" && os.Getenv("ACTIVATION_MATRIX_PENDING") != "absent"
+ if c.Pending {
+ raw, err := json.Marshal(systemJournal{Phase: systemPhaseInstalled, Target: "v2.0.0", PrevBuild: "v1.0.0", StartedAt: time.Now()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := writeFileDurable(filepath.Join(tx.updateDir(), "journal.json"), raw, 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ c.Point = point
+ c.Resume = true
+ c.Boot = string(boot)
+ raw, err := json.Marshal(c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := writeFileDurable(cursor, raw, 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeFileDurable(activationMatrixDir+"/control", []byte("reboot\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ t.Log(fmt.Sprintf("actual reboot prepared: timer=%t cut=%d point=%s", c.Timer, c.Cut, c.Point))
+ return
+ }
+}
+
+// Keep the production timer, service, executable and dependencies; make its
+// boot deadline overdue on every boot so activation really can fire it.
+func activationMatrixTimer(t *testing.T) {
+ activationProductionWrite(t, "/etc/systemd/system/carlos-adopt.timer.d/10-runner-deadline.conf", []byte("[Timer]\nOnBootSec=\nOnBootSec=1us\nRandomizedDelaySec=0\n"), 0644)
+ servingRunnerSystemctl(t, "daemon-reload")
+}
diff --git a/internal/host/activation_offline_linux.go b/internal/host/activation_offline_linux.go
index d2f7a35a7..cb9c9a3cf 100644
--- a/internal/host/activation_offline_linux.go
+++ b/internal/host/activation_offline_linux.go
@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"errors"
+ "fmt"
"os"
"path/filepath"
"reflect"
@@ -29,6 +30,7 @@ func activationOfflineUnits() []string {
type activationOffline struct {
units, state string
+ updateRoot string
systemctl func(context.Context, ...string) ([]byte, error)
drained func(context.Context) error
seedPreflight func(context.Context) error
@@ -39,23 +41,31 @@ type activationOfflineItem struct {
ActiveState string
Name string
Masked, Active bool
+ Inode uint64 `json:",omitempty"`
Mode uint32
UID, GID uint32
Size, Modified int64
Digest string
}
type activationOfflineRecord struct {
- V int
- Units string
- Phase string
- Items []activationOfflineItem
- SeedGuard *activationOfflineItem `json:",omitempty"`
- EdgePending bool `json:",omitempty"`
+ V int
+ Units string
+ Phase string
+ Items []activationOfflineItem
+ SeedGuard *activationOfflineItem `json:",omitempty"`
+ TriggersPending bool `json:",omitempty"`
+ EdgePending bool `json:",omitempty"`
}
+type activationInterruption struct{ error }
+
+func (e activationInterruption) Unwrap() error { return e.error }
+
func (t activationOffline) point(name string) error {
if t.checkpoint != nil {
- return t.checkpoint(name)
+ if err := t.checkpoint(name); err != nil {
+ return activationInterruption{err}
+ }
}
return nil
}
@@ -97,7 +107,7 @@ func (t activationOffline) status(ctx context.Context, name string) (string, str
}
load, active := fields["LoadState"], fields["ActiveState"]
if load != "loaded" && load != "masked" || active != "active" && active != "inactive" && active != "failed" {
- return "", "", errActivation
+ return load, active, errActivation
}
return load, active, nil
}
@@ -111,6 +121,7 @@ func activationUnitSnapshot(path, name string) (activationOfflineItem, error) {
if !ok || stat.Uid != 0 || st.Mode().Perm()&0022 != 0 && st.Mode()&os.ModeSymlink == 0 {
return item, errActivation
}
+ item.Inode = stat.Ino
item.Mode = uint32(st.Mode())
item.UID = stat.Uid
item.GID = stat.Gid
@@ -233,6 +244,9 @@ func (t activationOffline) checkItem(item activationOfflineItem) error {
if err != nil {
return err
}
+ if item.Inode == 0 {
+ got.Inode = 0
+ }
got.Active = item.Active
got.LoadState = item.LoadState
got.ActiveState = item.ActiveState
@@ -294,6 +308,11 @@ func (t activationOffline) replace(units, state *os.File, item activationOffline
return nil
}
func (t activationOffline) begin(ctx context.Context, dry bool) error {
+ release, err := t.settled(ctx, dry)
+ if err != nil {
+ return err
+ }
+ defer release()
if dry {
state, err := activationDir(t.state, false)
if err == nil {
@@ -417,55 +436,26 @@ func (t activationOffline) restore(ctx context.Context, dry bool) error {
if r.Phase == "prepared" || r.Phase == "seed-prepared" {
return errActivation
}
- if r.Phase == "done" && !r.EdgePending {
+ if r.Phase == "done" && !r.EdgePending && !r.TriggersPending {
return nil
}
+ release := func() {}
if r.Phase != "done" {
+ release, err = t.settled(ctx, dry)
+ if err != nil {
+ return err
+ }
+ defer func() { release() }()
for _, item := range r.Items {
if err = t.checkItem(item); err != nil {
- return err
+ return fmt.Errorf("%s: recorded file inode, content, or metadata changed: %w", item.Name, err)
}
}
}
if dry {
return nil
}
- reconcile := func(item activationOfflineItem) error {
- verb := "stop"
- if item.Active && !item.Masked {
- verb = "start"
- }
- if _, err := t.systemctl(ctx, verb, item.Name); err != nil {
- return err
- }
- if err := t.point(verb + "-" + item.Name); err != nil {
- return err
- }
- load, active, err := t.status(ctx, item.Name)
- if err != nil {
- return err
- }
- if verb == "stop" && active == "failed" {
- if _, err = t.systemctl(ctx, "reset-failed", item.Name); err != nil {
- return err
- }
- load, active, err = t.status(ctx, item.Name)
- if err != nil {
- return err
- }
- }
- wantLoad, wantActive := "loaded", "inactive"
- if item.Masked {
- wantLoad = "masked"
- }
- if item.Active && !item.Masked {
- wantActive = "active"
- }
- if load != wantLoad || active != wantActive {
- return errActivation
- }
- return nil
- }
+
edge := r.Items[len(r.Items)-1]
if r.Phase != "done" {
// A resumed complete record still owns every unit. Reconcile them all:
@@ -516,47 +506,96 @@ func (t activationOffline) restore(ctx context.Context, dry bool) error {
return err
}
if r.Phase != "complete" {
- // Recovery must be callable while service states are finalized.
- // Adoption and new updates remain excluded until done.
+ // All unit writers remain excluded until verified closure.
r.Phase = "complete"
if err = t.save(state, r); err != nil {
return err
}
}
- for _, item := range r.Items[len(activationTriggers) : len(r.Items)-1] {
- if err = reconcile(item); err != nil {
- return err
- }
- }
- for _, active := range []bool{false, true} {
- for _, item := range r.Items[:len(activationTriggers)] {
- if (item.Active && !item.Masked) == active {
- if err = reconcile(item); err != nil {
+ // Triggers stay inactive until durable closure. Starting an overdue timer
+ // here can run a service after that service has already been verified.
+ var differences []string
+ const rounds = 3
+ for round := 0; round < rounds; round++ {
+ differences = nil
+ for i, item := range r.Items {
+ want := item
+ if i < len(activationTriggers) && item.Active && !item.Masked {
+ want.Active = false
+ want.ActiveState = "inactive"
+ }
+ if item.Name == activationEdgeUnit {
+ want.Masked = true
+ want.LoadState = "masked"
+ want.Active = false
+ want.ActiveState = "inactive"
+ }
+ if err = t.reconcile(ctx, want); err != nil {
+ var interrupted activationInterruption
+ if errors.As(err, &interrupted) {
return err
}
+ differences = append(differences, err.Error())
}
}
+ if err = t.point("verify-close"); err != nil {
+ return err
+ }
+ differences = append(differences, t.closeDifferences(ctx, r)...)
+ if len(differences) == 0 {
+ break
+ }
+ }
+ if len(differences) != 0 {
+ return fmt.Errorf("claim activation: restore remains complete after %d verification rounds:\n%s", rounds, strings.Join(differences, "\n"))
}
- // Close durably before the edge can request its recovery dependency.
- // EdgePending resumes only the ordinary edge step, never the transaction.
- r.Phase, r.EdgePending = "done", true
+ r.Phase, r.EdgePending, r.TriggersPending = "done", true, true
if err = t.save(state, r); err != nil {
return err
}
+ // Recovery can take the update lock only after closure.
+ release()
+ release = func() {}
}
- if err = t.replace(units, state, edge, true); err != nil {
- return err
- }
- if _, err = t.systemctl(ctx, "daemon-reload"); err != nil {
- return err
+ // Release the edge file before triggers: ordinary adoption may rewrite units
+ // once done is durable. No transaction-owned replacement follows a trigger.
+
+ if r.EdgePending {
+ if err = t.replace(units, state, edge, true); err != nil {
+ return err
+ }
+ if _, err = t.systemctl(ctx, "daemon-reload"); err != nil {
+ return err
+ }
+ if err = t.point("edge-reload"); err != nil {
+ return err
+ }
+ if err = t.reconcile(ctx, edge); err != nil {
+ return err
+ }
+ load, active, e := t.status(ctx, edge.Name)
+ wantLoad, wantActive := activationRecordedState(edge)
+ if e != nil || load != wantLoad || active != wantActive {
+ return fmt.Errorf("%s: load=%s active=%s; want load=%s active=%s", edge.Name, load, active, wantLoad, wantActive)
+ }
}
- if err = t.point("edge-reload"); err != nil {
+ r.EdgePending = false
+ if err = t.save(state, r); err != nil {
return err
}
- if err = reconcile(edge); err != nil {
- return err
+ if r.TriggersPending {
+ for _, item := range r.Items[:len(activationTriggers)] {
+ if item.Active && !item.Masked {
+ if _, err = t.systemctl(ctx, "start", item.Name); err != nil {
+ return err
+ }
+ if err = t.point("start-" + item.Name); err != nil {
+ return err
+ }
+ }
+ }
+ r.TriggersPending = false
}
- r.EdgePending = false
return t.save(state, r)
}
@@ -614,3 +653,90 @@ func (t activationOffline) preview(ctx context.Context) ([]activationOfflinePrev
}
return out, nil
}
+
+func (t activationOffline) reconcile(ctx context.Context, item activationOfflineItem) error {
+ if item.ActiveState == "failed" {
+ load, active, err := t.status(ctx, item.Name)
+ wantLoad, wantActive := activationRecordedState(item)
+ if err != nil || load != wantLoad || active != wantActive {
+ return fmt.Errorf("%s: load=%s active=%s; want load=%s active=%s", item.Name, load, active, wantLoad, wantActive)
+ }
+ return nil
+ }
+ verb := "stop"
+ if item.Active && !item.Masked {
+ verb = "start"
+ }
+ if _, err := t.systemctl(ctx, verb, item.Name); err != nil {
+ return fmt.Errorf("%s: %w", item.Name, err)
+ }
+ if err := t.point(verb + "-" + item.Name); err != nil {
+ return err
+ }
+ _, active, err := t.status(ctx, item.Name)
+ if err != nil {
+ return fmt.Errorf("%s: %w", item.Name, err)
+ }
+ if verb == "stop" && active == "failed" {
+ if _, err := t.systemctl(ctx, "reset-failed", item.Name); err != nil {
+ return fmt.Errorf("%s: %w", item.Name, err)
+ }
+ if err := t.point("reset-failed-" + item.Name); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (t activationOffline) closeDifferences(ctx context.Context, r activationOfflineRecord) []string {
+ var differences []string
+ for i, item := range r.Items {
+ wantLoad, wantActive := activationRecordedState(item)
+ if i < len(activationTriggers) && item.Active && !item.Masked {
+ wantActive = "inactive"
+ }
+ if item.Name == activationEdgeUnit {
+ wantLoad, wantActive = "masked", "inactive"
+ }
+ load, active, err := t.status(ctx, item.Name)
+ if err != nil {
+ differences = append(differences, fmt.Sprintf("%s: load=%s active=%s; want load=%s active=%s: %v", item.Name, load, active, wantLoad, wantActive, err))
+ } else if load != wantLoad || active != wantActive {
+ differences = append(differences, fmt.Sprintf("%s: load=%s active=%s; want load=%s active=%s", item.Name, load, active, wantLoad, wantActive))
+ }
+ suffix := ".original"
+ if item.Name == activationEdgeUnit && !item.Masked {
+ suffix = ".mask"
+ }
+ if err := t.checkItem(item); err != nil || !sameActivationFile(filepath.Join(t.units, item.Name), filepath.Join(t.state, item.Name+suffix)) {
+ differences = append(differences, fmt.Sprintf("%s: recorded file inode, content, or metadata changed", item.Name))
+ }
+ }
+ if r.SeedGuard != nil {
+ g := activationOffline{units: filepath.Join(t.units, "carlos-edge.service.d"), state: t.state}
+ if err := g.checkItem(*r.SeedGuard); err != nil || !sameActivationFile(filepath.Join(g.units, r.SeedGuard.Name), filepath.Join(t.state, r.SeedGuard.Name+".original")) {
+ differences = append(differences, fmt.Sprintf("%s: recorded file inode, content, or metadata changed", r.SeedGuard.Name))
+ }
+ }
+ if err := t.updateSettled(); err != nil {
+ differences = append(differences, err.Error())
+ }
+ return differences
+}
+
+func activationRecordedState(item activationOfflineItem) (string, string) {
+ load, active := item.LoadState, item.ActiveState
+ if load == "" {
+ load = "loaded"
+ if item.Masked {
+ load = "masked"
+ }
+ }
+ if active == "" {
+ active = "inactive"
+ if item.Active {
+ active = "active"
+ }
+ }
+ return load, active
+}
diff --git a/internal/host/activation_offline_rootrunner_linux_test.go b/internal/host/activation_offline_rootrunner_linux_test.go
index 8d64c0ee5..e3148ba4a 100644
--- a/internal/host/activation_offline_rootrunner_linux_test.go
+++ b/internal/host/activation_offline_rootrunner_linux_test.go
@@ -35,7 +35,7 @@ func offlineFixture(t *testing.T) (activationOffline, string) {
}
return nil, nil
}
- return activationOffline{units: units, state: state, systemctl: ctl, drained: func(context.Context) error { return nil }}, d
+ return activationOffline{units: units, state: state, updateRoot: filepath.Join(d, "system/router"), systemctl: ctl, drained: func(context.Context) error { return nil }}, d
}
func TestRootActivationModelOfflineInterruptions(t *testing.T) {
for _, phase := range []string{"begin", "restore"} {
@@ -187,7 +187,7 @@ func TestRootActivationModelEdgeLast(t *testing.T) {
if args[1] == "carlos-edge.service" && offlineRecord(t, tx).Phase != "done" {
t.Fatal("edge started before durable completion")
}
- if args[1] != "carlos-edge.service" {
+ if args[1] != "carlos-edge.service" && offlineRecord(t, tx).Phase != "done" {
target, _ := os.Readlink(filepath.Join(tx.units, "carlos-edge.service"))
if target != "/dev/null" {
t.Fatal("edge unmasked before triggers restored")
diff --git a/internal/host/activation_production_rootrunner_linux_test.go b/internal/host/activation_production_rootrunner_linux_test.go
index d390c7680..e5aae5ced 100644
--- a/internal/host/activation_production_rootrunner_linux_test.go
+++ b/internal/host/activation_production_rootrunner_linux_test.go
@@ -356,10 +356,12 @@ func TestRootActivationProductionRecoveryGate(t *testing.T) {
if err := (&systemApplier{etcDir: "/etc"}).Run(t.Context(), false); !errors.Is(err, errActivation) {
t.Fatal("complete allowed update", err)
}
- servingRunnerSystemctl(t, "start", "carlos-system-recover.service")
+ if _, err := tx.systemctl(t.Context(), "start", "carlos-system-recover.service"); err == nil {
+ t.Fatal("complete allowed recovery")
+ }
raw, err := tx.systemctl(t.Context(), "show", "carlos-system-recover.service", "--property=Result", "--value")
- if err != nil || strings.TrimSpace(string(raw)) != "success" {
- t.Fatal("complete blocked real recovery", string(raw), err)
+ if err != nil || strings.TrimSpace(string(raw)) != "exit-code" {
+ t.Fatal("complete did not exclude real recovery", string(raw), err)
}
tx.checkpoint = nil
if err := tx.restore(t.Context(), false); err != nil {
diff --git a/internal/host/activation_restore_order_rootrunner_linux_test.go b/internal/host/activation_restore_order_rootrunner_linux_test.go
index 3cf264aa0..c12e93cc1 100644
--- a/internal/host/activation_restore_order_rootrunner_linux_test.go
+++ b/internal/host/activation_restore_order_rootrunner_linux_test.go
@@ -132,7 +132,7 @@ func TestRootActivationModelRepairExclusion(t *testing.T) {
}
stop := errors.New("lost adoption timer start reply")
tx.checkpoint = func(name string) error {
- if name == "start-carlos-adopt.timer" {
+ if name == "verify-close" {
return stop
}
return nil
@@ -205,7 +205,7 @@ func TestRootActivationAdoptionGate(t *testing.T) {
t.Fatal("gate phase", phase, err)
}
err = activationTransactionAllowed(etc, true)
- if (err == nil) != (phase == "complete" || phase == "done") {
+ if (err == nil) != (phase == "done") {
t.Fatal("recovery gate phase", phase, err)
}
}
diff --git a/internal/host/activation_settled_linux.go b/internal/host/activation_settled_linux.go
new file mode 100644
index 000000000..48713a5f7
--- /dev/null
+++ b/internal/host/activation_settled_linux.go
@@ -0,0 +1,132 @@
+//go:build linux
+
+package host
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "syscall"
+
+ "golang.org/x/sys/unix"
+)
+
+func (t activationOffline) updateDir() string {
+ if t.updateRoot != "" {
+ return t.updateRoot
+ }
+ return systemRootDir("/opt/carlos")
+}
+
+// Hold the updater's lock through admission, so an already admitted update
+// cannot race the snapshot. Apply rechecks the transaction after taking it.
+func (t activationOffline) settled(ctx context.Context, dry bool) (func(), error) {
+ refuse := func(err error) (func(), error) {
+ return nil, fmt.Errorf("claim activation: settle the update first: %s", err)
+ }
+ if err := ctx.Err(); err != nil {
+ return refuse(err)
+ }
+ dir, err := activationDir(t.updateDir(), !dry)
+ if errors.Is(err, os.ErrNotExist) && dry {
+ if err := t.updateUnitsSettled(ctx); err != nil {
+ return nil, err
+ }
+ return func() {}, t.updateSettled()
+ }
+ if err != nil {
+ return refuse(err)
+ }
+ defer dir.Close()
+ flags := unix.O_RDWR | unix.O_CLOEXEC | unix.O_NOFOLLOW | unix.O_NONBLOCK
+ if !dry {
+ flags |= unix.O_CREAT
+ }
+ fd, err := unix.Openat(int(dir.Fd()), systemLockFile, flags, 0600)
+ if errors.Is(err, os.ErrNotExist) && dry {
+ if err := t.updateUnitsSettled(ctx); err != nil {
+ return nil, err
+ }
+ return func() {}, t.updateSettled()
+ }
+ if err != nil {
+ return refuse(err)
+ }
+ lock := os.NewFile(uintptr(fd), filepath.Join(t.updateDir(), systemLockFile))
+ st, err := lock.Stat()
+ if err != nil || !st.Mode().IsRegular() || st.Mode().Perm()&0022 != 0 || st.Sys().(*syscall.Stat_t).Uid != 0 {
+ lock.Close()
+ return refuse(errActivation)
+ }
+ if err := unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB); err != nil {
+ lock.Close()
+ return refuse(fmt.Errorf("%s: %w", lock.Name(), err))
+ }
+ if err := t.updateUnitsSettled(ctx); err != nil {
+ lock.Close()
+ return nil, err
+ }
+
+ if err := t.updateSettled(); err != nil {
+ lock.Close()
+ return nil, err
+ }
+ return func() { lock.Close() }, nil
+}
+
+func (t activationOffline) updateSettled() error {
+ for _, path := range []string{
+ filepath.Join(t.updateDir(), "journal.json"),
+ filepath.Join(t.updateDir(), rearmPendingFile),
+ filepath.Join(t.updateDir(), "staging"),
+ filepath.Join(filepath.Dir(t.updateDir()), "guard", "update-begun.json"),
+ } {
+ _, err := os.Lstat(path)
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ if err == nil {
+ err = errActivation
+ }
+ return fmt.Errorf("claim activation: settle the update first: %s: %w", path, err)
+ }
+ return nil
+}
+
+func (t activationOffline) updateUnitsSettled(ctx context.Context) error {
+ for _, name := range []string{"carlos-system-update.service", "carlos-system-recover.service"} {
+ _, active, err := t.status(ctx, name)
+ if err != nil || active != "inactive" && active != "failed" {
+ return fmt.Errorf("claim activation: settle the update first: %s: active=%s", name, active)
+ }
+ }
+ return nil
+}
+
+// The systemd updater gets CARLOS_OPT_DIR from protected host.env. An
+// operator's shell must not redirect admission to a different lock or journal.
+func activationConfiguredUpdateRoot(config, override string) (string, error) {
+ opt := "/opt/carlos"
+ raw, err := activationRead(config, false)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return "", err
+ }
+ if err == nil {
+ env, err := activationEnvironment(raw)
+ if err != nil {
+ return "", err
+ }
+ if env["CARLOS_OPT_DIR"] != "" {
+ opt = env["CARLOS_OPT_DIR"]
+ }
+ }
+ if !filepath.IsAbs(opt) || filepath.Clean(opt) != opt {
+ return "", errActivation
+ }
+ if override != "" && override != opt {
+ return "", errors.New("claim activation: settle the update first: CARLOS_OPT_DIR differs from protected configuration")
+ }
+ return systemRootDir(opt), nil
+}
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go
index 57fc290f1..fbef3d9fc 100644
--- a/internal/host/systemapply.go
+++ b/internal/host/systemapply.go
@@ -309,6 +309,9 @@ func (a *systemApplier) Run(ctx context.Context, recover bool) (retErr error) {
a.ops.logf("system apply: another apply is running; leaving it to finish")
return nil
}
+ if err := activationTransactionAllowed(a.etcDir, recover); err != nil {
+ return err
+ }
if a.floorHook != nil {
if err := a.floorHook(a); err != nil {
return err
diff --git a/internal/host/systemunits.go b/internal/host/systemunits.go
index ce722e39c..99d040bd3 100644
--- a/internal/host/systemunits.go
+++ b/internal/host/systemunits.go
@@ -64,6 +64,11 @@ func ensureSystemUnits(etcDir, lockPath string, systemctl, probe func(args ...st
return
}
+ if err := activationAdoptionAllowed(etcDir); err != nil {
+ logf("adopt: system units: %v", err)
+ return
+ }
+
claimSettings, settingsErr := readClaimUnitSettings(filepath.Join(etcDir, "carlos", "host.env"))
if settingsErr != nil {
logf("adopt: instance-control unit settings: %v", settingsErr)
diff --git a/internal/provision/testdata/plan14-root-reads-activation-tools.tsv b/internal/provision/testdata/plan14-root-reads-activation-tools.tsv
index 94bb7bdbe..fe9b6a020 100644
--- a/internal/provision/testdata/plan14-root-reads-activation-tools.tsv
+++ b/internal/provision/testdata/plan14-root-reads-activation-tools.tsv
@@ -1,7 +1,7 @@
-# Activation-tools runs 1–5: proposals only; loaded after all existing overlays.
+# Activation-tools runs 1–6: proposals only; loaded after all existing overlays.
@coverage . efeeda08e816fc2c7e3aa451b06cce95ab5778af79682290cf98a60d8b9a841a
@coverage internal/claimactivation 625d82dce14bae92f619077ca92a8c9066a9446545dfcc96a6f818e89e20d4c2
-@coverage internal/host cd645b7f97bbb52b333f7fe60419d2d8b8cc41d9237d70b38495c525e8528167
+@coverage internal/host e9a3134343f930cb50566629d2c2d8e3613e75557036efa67158af6a52c113f2
@coverage internal/hostfence 552570510fb62d2a8822a3f24fa76aeb1437c0a9516966dc5ce15ef58e3fb4c6
internal/claimactivation/runner.go Runner.Run authority F,G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=trusted operator phases bind expected signed roster revision, exact deployment and actors; verify each write; dry-run overlay never calls backing Put file:Read@310,file:Read@5986,file:Read@6318 683059aedf27656d4b49c88102089b2bb2f2ce7fcc108ff8545e52a86a0f8385
internal/claimactivation/runner.go checkedStore.Delete authority F,G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=trusted operator phases bind expected signed roster revision, exact deployment and actors; verify each write; dry-run overlay never calls backing Put 6bc9103a30cfd53cea59d611ccacb7dcc0ca0ea620b403497ef543f5bdaa0217
@@ -9,7 +9,7 @@ internal/claimactivation/runner.go checkedStore.Get authority F,G,J4 rev3=PROPOS
internal/claimactivation/runner.go checkedStore.Put authority F,G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=trusted operator phases bind expected signed roster revision, exact deployment and actors; verify each write; dry-run overlay never calls backing Put file:Read@1357 26c6e1f7cb2a057ef8b19deb30bc18fd62fe08f81ff48fd4022bdfc3dd22d6b0
internal/host/activation_command.go RunSystemClaims authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call cc8289fbcb3f34d8d43cd912a6a43ace727ed7289c6c32c5d35c8d00e6194cf3
internal/host/activation_command_linux.go activationProcessesDrained authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:ReadDir@219,file:Stat@458 104e95618024c8f84acb7f18c39cdc18ac6834d34242a075f4652fdb6ed84e75
-internal/host/activation_command_linux.go runSystemClaims authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Read@1518 9443a714df9d6a2dbebb48986ae8ef0028a724a853098b316fa985d9a69b9e8b
+internal/host/activation_command_linux.go runSystemClaims authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Read@1518 aef08108659e86b3fd122d584b417a82ae0dfdb8cd053e46577c02014d2daeb2
internal/host/activation_command_other.go runSystemClaims authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call fcf45d665275c2ac99ff9bfc37302217bb6134b34ff74c3b17f50d1348760151
internal/host/activation_console.go activationConsoleEnvironment authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 9d1144ac45e5849f43872d12ac6389c620ad07f0b2e69575a1353bfaf218b9fd
internal/host/activation_install.go activationEnvironment authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 289ff3c1267e613afd102f83f68592bfd99dafc3d6b7eaba05e630e6b2e7be3e
@@ -22,20 +22,20 @@ internal/host/activation_install_linux.go installActivationConsole authority G,J
internal/host/activation_install_linux.go installActivationControl authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:ReadFile@616 16e3c76c1d1d06fc429725cfeaee21b17d6a37620da287b40482b12e2f6b20a5
internal/host/activation_install_linux.go installActivationEvidenceKey authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 74f311fcb31aec95f5760ae9781186cc44a6f650858349c379e82f3a1fc6fd10
internal/host/activation_install_linux.go installActivationFenceCredentials authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 9e18de6df5fb9d3e6898967c9ed91e72d19faf5fd6bff7331ee654d76dfe7075
-internal/host/activation_offline_linux.go activationOffline.begin authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call c043caa233ba91721b2a7a7c5792d847b2ebd129ac2975160e4af224bef349fe
-internal/host/activation_offline_linux.go activationOffline.checkItem authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call db9c3080a32a91bbc92b9d57b26807a87bff55950b9b5405f0917101f30a0186
-internal/host/activation_offline_linux.go activationOffline.dirs authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Stat@384 cdbba773717e890be772460b0f8235b4f7f0e158a11977740a2c963aa3a1c923
-internal/host/activation_offline_linux.go activationOffline.point authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 34292b297a697dbb9fd4c1b067900a7cdcdd0b41dba0d7c7dc9764f633424e43
-internal/host/activation_offline_linux.go activationOffline.prepare authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Lstat@947,file:Readlink@979 f3723f40487a5fe0fd43a8369be180b3238c808d9ce117612d744058a9fb0861
-internal/host/activation_offline_linux.go activationOffline.preview authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call a092bb6fb96f64e406a266cef59a4516145a7f58935ff8d1bbc42a96a653aab1
-internal/host/activation_offline_linux.go activationOffline.read authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 6ffaa998c2441b9a2d7889275b526bace11feb269df6856527b199a40cfa5bec
-internal/host/activation_offline_linux.go activationOffline.replace authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 2a64167ac7053a5c73fe64aa9fea7e4f8db3b1409c7e34fa78c1b842328ccdbb
-internal/host/activation_offline_linux.go activationOffline.restore authority G,J4 rev3=PROPOSED-activation-tools;identity=root recovery or explicit offline operator;basis=complete permits recovery only; all recorded services and triggers reconciled before durable done; done releases repair before ordinary guarded edge start; pending edge continuation does not reopen transaction; absent transaction preserves existing behavior b41ef5f53c7afe6f1f09fb61f2fd7f617632875f373957b76204d71ea946ce49
-internal/host/activation_offline_linux.go activationOffline.save authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 3082a1585eb287687e05756f495aae7de7e11aab6e96bbee920c50b4807ab9b7
-internal/host/activation_offline_linux.go activationOffline.status authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 22467426d7d4cdb3d3f41dfab898f5a5ee49c34a58d4489ab53d6f04ab213587
-internal/host/activation_offline_linux.go activationOfflineUnits authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 372c65c44288c766e9b2aa0ece8cbc55c95fb1ccae7e9bae80a5bfcda2550b4b
-internal/host/activation_offline_linux.go activationUnitSnapshot authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Lstat@135,file:ReadFile@819,file:Readlink@539 bcb4d45f273e849f03715cfb3a6b49b25b3e128d55e88b15a9528175e433ad04
-internal/host/activation_offline_linux.go sameActivationFile authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Lstat@107,file:Lstat@53 72cf415a823352f1fcf5aba8452df074896aacc851da3dfea9a635b37c44669b
+internal/host/activation_offline_linux.go activationOffline.begin authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior 5a266bc0738e51f4813bf83c54ef7e0fb7d29fbf73af8e2c86ffa10e16049843
+internal/host/activation_offline_linux.go activationOffline.checkItem authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior 55e4f594ebd4666d8b5eff5eb995a163a6f9ef3838b1d6db5f705a90b93a16e8
+internal/host/activation_offline_linux.go activationOffline.dirs authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior file:Stat@384 cdbba773717e890be772460b0f8235b4f7f0e158a11977740a2c963aa3a1c923
+internal/host/activation_offline_linux.go activationOffline.point authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior c28de19acebdc98b1b836151fbba47103ccb431f40d79984fee55a0b921ad0e0
+internal/host/activation_offline_linux.go activationOffline.prepare authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior file:Lstat@947,file:Readlink@979 f3723f40487a5fe0fd43a8369be180b3238c808d9ce117612d744058a9fb0861
+internal/host/activation_offline_linux.go activationOffline.preview authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior a092bb6fb96f64e406a266cef59a4516145a7f58935ff8d1bbc42a96a653aab1
+internal/host/activation_offline_linux.go activationOffline.read authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior 6ffaa998c2441b9a2d7889275b526bace11feb269df6856527b199a40cfa5bec
+internal/host/activation_offline_linux.go activationOffline.replace authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior 2a64167ac7053a5c73fe64aa9fea7e4f8db3b1409c7e34fa78c1b842328ccdbb
+internal/host/activation_offline_linux.go activationOffline.restore authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior; only recorded-active unmasked triggers defer; all other trigger states compare exactly df2ea187884d439b7b6cac083b0ff53224bd680ab21d3f2deced4296cbcb41e0
+internal/host/activation_offline_linux.go activationOffline.save authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior 3082a1585eb287687e05756f495aae7de7e11aab6e96bbee920c50b4807ab9b7
+internal/host/activation_offline_linux.go activationOffline.status authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior 616c3058401766ada7c16395412aaca50b4d68be8d3a2e57ae969a53e21292ff
+internal/host/activation_offline_linux.go activationOfflineUnits authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior 372c65c44288c766e9b2aa0ece8cbc55c95fb1ccae7e9bae80a5bfcda2550b4b
+internal/host/activation_offline_linux.go activationUnitSnapshot authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior file:Lstat@135,file:ReadFile@842,file:Readlink@562 c55777e2f221a6e9c6f2ab5a66fb875596e336e0b403b3479d6c7c04d4591188
+internal/host/activation_offline_linux.go sameActivationFile authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior file:Lstat@107,file:Lstat@53 72cf415a823352f1fcf5aba8452df074896aacc851da3dfea9a635b37c44669b
internal/hostfence/activation_credentials.go ActivationCredentialReceipt authority F,J4 rev3=PROPOSED-activation-tools;identity=root fence service and explicit installer uid 0;basis=dedicated root fence credentials retain 0400 compatibility; 0600 needs a matching protected installation receipt; absent explicit installation keeps prior refusal 63ffd51457e632687bc44f776fd5c0335748a899e623ff67a0dea31be4ffa7f2
internal/hostfence/activation_credentials.go ValidateActivationCredentials authority F,J4 rev3=PROPOSED-activation-tools;identity=root fence service and explicit installer uid 0;basis=dedicated root fence credentials retain 0400 compatibility; 0600 needs a matching protected installation receipt; absent explicit installation keeps prior refusal 9a08dd0349bd4bd43ef44d6e621876d43e8c151dcb355ef94c9f134776f0f4d9
internal/hostfence/activation_credentials.go VerifyActivationCredentialReceipt authority F,J4 rev3=PROPOSED-activation-tools;identity=root fence service and explicit installer uid 0;basis=dedicated root fence credentials retain 0400 compatibility; 0600 needs a matching protected installation receipt; absent explicit installation keeps prior refusal bd06fbd520d6808a635f8e3a4a8a5b8a389beb1d1374d56848853b1ad6855d84
@@ -60,11 +60,19 @@ internal/host/activation_seed_linux.go activationOffline.seedRemask authority G,
internal/host/activation_seed_linux.go const activationSeedGuardOriginal authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=exact guard replacement journaled before unmask; retain original bytes metadata and inode outside search path; remask on returned errors and interrupted retry preflight; seed only across crash/reboot de4e081ef3584a30477e20d3d30bcc0d1f3582607be533921c3896d6548440f2
internal/host/activation_seed_linux.go const activationSeedGuardReplacement authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=exact guard replacement journaled before unmask; retain original bytes metadata and inode outside search path; remask on returned errors and interrupted retry preflight; seed only across crash/reboot d5d0cacb84f15b9d5098e05f41a7981852dc53add1c20b946a1a870ef3dc2736
internal/host/activation_seed_linux.go activationSeedReadAt authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=protected directory descriptor; nofollow nonblocking regular-file read; root ownership and bounded bytes; retained hardlinks permitted only in seed configuration inspection file:Fstat@368,file:Openat@148,file:ReadAll@545 0fecdfd3967cc9db0f5a5f2cb81a8eb4d440ffb145ab9ef604c77f654d74e94e
-internal/host/activation_adoption_linux.go activationAdoptionAllowed authority G,J4 rev3=PROPOSED-activation-tools;identity=root recovery or explicit offline operator;basis=complete permits recovery only; all recorded services and triggers reconciled before durable done; done releases repair before ordinary guarded edge start; pending edge continuation does not reopen transaction; absent transaction preserves existing behavior 9f5c63bae1686a4eced50eed59019280bd1c7beb6e1e9f5d1efbd5994e2011d7
+internal/host/activation_adoption_linux.go activationAdoptionAllowed authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior 9f5c63bae1686a4eced50eed59019280bd1c7beb6e1e9f5d1efbd5994e2011d7
internal/host/activation_adoption_other.go activationAdoptionAllowed authority G,J4 rev3=PROPOSED-activation-tools;identity=root adoption or explicit offline operator;basis=only an explicit protected offline transaction excludes adoption and automatic repair until done; absent transaction preserves existing behavior; complete is durable before edge release; prior authority constraints retained. 9a70a93e601ea57fe2ce1780cc6cf878ea3cde656770fe8c400d346fbdbca361
-internal/host/activation_offline_linux.go var activationEdgeUnit authority G,J4 rev3=PROPOSED-activation-tools;identity=root adoption or explicit offline operator;basis=only an explicit protected offline transaction excludes adoption and automatic repair until done; absent transaction preserves existing behavior; complete is durable before edge release; prior authority constraints retained. 42c79dbb523cf08c19f23d2e4f1754902524276b86f58f0d3d5bfb6117d3fcc6
+internal/host/activation_offline_linux.go var activationEdgeUnit authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior 42c79dbb523cf08c19f23d2e4f1754902524276b86f58f0d3d5bfb6117d3fcc6
internal/host/agent.go runAdoptUnderRootRelease authority B3,E1,E2,E3 rev3=PROPOSED-activation-tools;identity=root adoption or explicit offline operator;basis=only an explicit protected offline transaction excludes adoption and automatic repair until done; absent transaction preserves existing behavior; complete is durable before edge release; prior authority constraints retained. rev3=PROPOSED-p3;run=16;identity=root adopt/update/control uid 0;basis=no census selects legacy proof/history and update effects; proof observation cannot fetch signed recovery manifests on that path; participating adoption retains root release exclusion and independent generation pins whole:Route@11179,whole:Route@18368,whole:Route@18404,whole:Route@8481 2f755c7ed82fcac622a118d1e4ad0b5d746de2b97e0456e125114e12351cd0c8
-internal/host/systemunits.go ensureSystemUnits authority F,J4 rev3=PROPOSED-activation-tools;identity=root adoption or explicit offline operator;basis=only an explicit protected offline transaction excludes adoption and automatic repair until done; absent transaction preserves existing behavior; complete is durable before edge release; prior authority constraints retained. root-input;kept=F;scope=protected-decision-only file:OpenFile@375,file:ReadFile@3018,file:ReadFile@3151,file:ReadFile@4065,file:Stat@2927 25aa3ae39d4ccbb5aa041caceb98fe2512c4a0ccfe1a1d34776750d46eff3a06
-internal/host/systemapply.go systemApplier.Run authority D,F,J4 rev3=PROPOSED-activation-tools;identity=root recovery or explicit offline operator;basis=complete permits recovery only; all recorded services and triggers reconciled before durable done; done releases repair before ordinary guarded edge start; pending edge continuation does not reopen transaction; absent transaction preserves existing behavior file:OpenFile@572 c323ba31cd6960119b09163c7eff7f4e7a86b3d78eeffe14baa96e1ced24847c
-internal/host/activation_adoption_linux.go activationTransactionAllowed authority G,J4 rev3=PROPOSED-activation-tools;identity=root recovery or explicit offline operator;basis=complete permits recovery only; all recorded services and triggers reconciled before durable done; done releases repair before ordinary guarded edge start; pending edge continuation does not reopen transaction; absent transaction preserves existing behavior file:Lstat@157,file:Stat@377 2db0bde7302c754e9115cefc4bb211e1ab479383667067f6e1872c944f64a0c3
-internal/host/activation_adoption_other.go activationTransactionAllowed authority G,J4 rev3=PROPOSED-activation-tools;identity=root recovery or explicit offline operator;basis=complete permits recovery only; all recorded services and triggers reconciled before durable done; done releases repair before ordinary guarded edge start; pending edge continuation does not reopen transaction; absent transaction preserves existing behavior 8c451f30ca31ccca7618f0fd6126ed7d31267c74213c0adb6475e6d504c80723
+internal/host/systemunits.go ensureSystemUnits authority F,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior file:OpenFile@375,file:ReadFile@3129,file:ReadFile@3262,file:ReadFile@4176,file:Stat@3038 eda788ec9bfb20192d503aa2ce07f707871de3bba111830f00bc2be0b5c8daa2
+internal/host/systemapply.go systemApplier.Run authority D,F,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior file:OpenFile@572 3e714f6843366bdc621c416c6be2a55f1d3ed5de53f1ee12948f73fe855f6996
+internal/host/activation_adoption_linux.go activationTransactionAllowed authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=all automatic writers excluded through complete; only durable done releases ordinary recovery and repair; absent transaction retains legacy behavior file:Lstat@157,file:Stat@377 fb9878a1a768b7faf9244702c330a14e062fe4e6e6b3b0753ae13034384dcf4a
+internal/host/activation_adoption_other.go activationTransactionAllowed authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=all automatic writers excluded through complete; only durable done releases ordinary recovery and repair; absent transaction retains legacy behavior 8c451f30ca31ccca7618f0fd6126ed7d31267c74213c0adb6475e6d504c80723
+internal/host/activation_settled_linux.go activationOffline.settled authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior file:Openat@663,file:Stat@1011 176b2ba6cd9eb6af75ded2a7e27c4f6f8cd6ff1107f5949b1c40f884e651644a
+internal/host/activation_settled_linux.go activationOffline.updateSettled authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior file:Lstat@317 38adccbf4e50966a54a966af12c3afffc8c02dbcdf43825423bb60ee92eb9c80
+internal/host/activation_offline_linux.go activationOffline.closeDifferences authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior; only recorded-active unmasked triggers defer; all other trigger states compare exactly a6f36043481b7219e399f0711e76b8150f82a24e977f3a648d58e8592b58628b
+internal/host/activation_offline_linux.go activationOffline.reconcile authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior c4f48a941e6cd7ea2d1657ecb7356f1c2978fa879574906af3df3700178d474e
+internal/host/activation_settled_linux.go activationOffline.updateDir authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior d729025e9d11aa6007ec09318e5e4b84cd14ffda17059d305bbbffe982740fb1
+internal/host/activation_settled_linux.go activationOffline.updateUnitsSettled authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator;basis=system update and recovery units must be inactive or failed on admission and resume; transient and unknown states refuse before transaction changes f220fdee955f9a1f27d00f2f7678f143aaf15ac0819e3ec5e208b951b8939c1f
+internal/host/activation_offline_linux.go activationRecordedState authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator;basis=exact recorded load and active state; boolean fallback only for older records 99ed5f22fad14f6a8dc572ec7166a1a04415f7067dd1cb4e624b2ad5797947f9
+internal/host/activation_settled_linux.go activationConfiguredUpdateRoot authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator;basis=protected host.env binds admission to actual updater lock and journals; conflicting shell override refused 8f9e59c225857ef0601d1f6800bc7e0938f0800589dfaf614c6df93b854dcee1