carlos / platform Public

WIP claim Plan 14 Part 3 checkpoint 3 (not for merge)

Seeds floor (unpublished): storage, legacy denies, acknowledgement, selectors; G2 non-empty historic migration with signed stop import, startup verification, predecessor-gated re-admission; reboot phase retry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 51a4253f48f65f2cb9120233918eebe57095968a parent 3ca9a31
35 files changed, +1269 −78
  • internal/console/seedfloor.go +2 −1
  • internal/console/seedfloor_test.go +15 −0
  • internal/edgesfile/snapshot.go +2 −0
  • internal/host/claimconstruction.go +4 −0
  • internal/host/edgesfile.go +2 −0
  • internal/host/movesfloor.go +5 −0
  • internal/host/plan14_root_migration_test.go +60 −0
  • internal/host/plan9fix4_claimjournal_migration_linux_test.go +1 −0
  • internal/host/relocationsourcehold_linux_test.go +1 −0
  • internal/host/rootmigration_bootstrap_root_linux_test.go +70 −2
  • internal/host/rootmigration_evidence.go +64 −0
  • internal/host/rootmigration_historic_linux_test.go +148 −0
  • internal/host/rootmigration_production_linux.go +25 −21
  • internal/host/rootmigration_reboot_root_linux_test.go +150 −0
  • internal/host/rootmigration_startup.go +127 −0
  • internal/host/rootmigration_stopped_linux.go +46 −0
  • internal/host/rootmovesfloor.go +5 −2
  • internal/host/rootmovesfloor_boot.go +22 −0
  • internal/host/rootmovesfloor_boot_root_linux_test.go +36 −3
  • internal/host/rootmovesfloor_crash_root_linux_test.go +10 −4
  • internal/host/rootmovesfloor_refresh.go +4 −4
  • internal/host/rootmovesfloor_source.go +29 −6
  • internal/host/rootseedsfloor.go +142 −0
  • internal/host/rootseedsfloor_root_linux_test.go +84 −0
  • internal/host/rootseedsfloor_test.go +44 −0
  • internal/host/seedsfloor.go +8 −0
  • internal/host/systemapply.go +15 −5
  • internal/hostguard/guard.go +1 −1
  • internal/hostguard/plan14_root_fences_test.go +22 −2
  • internal/hostguard/root.go +1 −1
  • internal/hostguard/rootmigration.go +21 −0
  • internal/hostguard/rootmigration_test.go +45 −0
  • internal/hostguard/rootview.go +1 −1
  • internal/provision/testdata/plan14-root-reads-p3.tsv +56 −25
  • internal/release/systempointer.go +1 −0

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	303.951s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.858s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.429s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.740s
ok  	github.com/carlosframework/platform/internal/activator	249.905s
ok  	github.com/carlosframework/platform/internal/agentsock	0.020s
ok  	github.com/carlosframework/platform/internal/alerting	0.004s
ok  	github.com/carlosframework/platform/internal/analytics	0.024s
ok  	github.com/carlosframework/platform/internal/appconfig	2.580s
ok  	github.com/carlosframework/platform/internal/apperr	0.094s
ok  	github.com/carlosframework/platform/internal/applog	0.153s
ok  	github.com/carlosframework/platform/internal/awsinv	0.005s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.005s
ok  	github.com/carlosframework/platform/internal/boxid	0.930s
?   	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.009s
ok  	github.com/carlosframework/platform/internal/caps	0.012s
ok  	github.com/carlosframework/platform/internal/certwarm	0.004s
ok  	github.com/carlosframework/platform/internal/cgroup	0.754s
ok  	github.com/carlosframework/platform/internal/changelog	0.324s
ok  	github.com/carlosframework/platform/internal/clamd	0.058s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	394.074s
?   	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.857s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.010s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.003s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.394s
ok  	github.com/carlosframework/platform/internal/doorkey	0.007s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	26.857s
?   	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.134s
ok  	github.com/carlosframework/platform/internal/gate	1.655s
ok  	github.com/carlosframework/platform/internal/genselect	0.016s
ok  	github.com/carlosframework/platform/internal/geo	0.018s
ok  	github.com/carlosframework/platform/internal/geoip	0.030s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.153s
ok  	github.com/carlosframework/platform/internal/host	146.806s
ok  	github.com/carlosframework/platform/internal/hostactors	0.490s
ok  	github.com/carlosframework/platform/internal/hostbox	2.332s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.578s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.007s
ok  	github.com/carlosframework/platform/internal/hostexec	5.146s
ok  	github.com/carlosframework/platform/internal/hostfence	0.040s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.483s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.019s
ok  	github.com/carlosframework/platform/internal/hostguard	1.027s
ok  	github.com/carlosframework/platform/internal/hostops	1.622s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.002s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.002s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.494s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.142s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.005s
ok  	github.com/carlosframework/platform/internal/instancectl	4.190s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.718s
ok  	github.com/carlosframework/platform/internal/instancefs	0.006s
ok  	github.com/carlosframework/platform/internal/instancekey	0.007s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.006s
ok  	github.com/carlosframework/platform/internal/layout	1.541s
ok  	github.com/carlosframework/platform/internal/lease	9.810s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.008s
ok  	github.com/carlosframework/platform/internal/ledger	0.024s
ok  	github.com/carlosframework/platform/internal/mail	0.188s
ok  	github.com/carlosframework/platform/internal/maintenance	0.083s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.558s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.068s
ok  	github.com/carlosframework/platform/internal/metrics	0.007s
ok  	github.com/carlosframework/platform/internal/moves	21.363s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.794s
ok  	github.com/carlosframework/platform/internal/objstore	0.205s
ok  	github.com/carlosframework/platform/internal/ops	0.186s
ok  	github.com/carlosframework/platform/internal/park	0.231s
ok  	github.com/carlosframework/platform/internal/privatefile	0.004s
ok  	github.com/carlosframework/platform/internal/provision	17.937s
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.388s
ok  	github.com/carlosframework/platform/internal/relay	7.244s
ok  	github.com/carlosframework/platform/internal/release	22.260s
ok  	github.com/carlosframework/platform/internal/rollout	3.920s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.008s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.007s
ok  	github.com/carlosframework/platform/internal/rootstate	0.027s
ok  	github.com/carlosframework/platform/internal/runs	0.559s
ok  	github.com/carlosframework/platform/internal/s3compat	0.006s
ok  	github.com/carlosframework/platform/internal/safefile	0.005s
ok  	github.com/carlosframework/platform/internal/schedule	2.500s
ok  	github.com/carlosframework/platform/internal/seeds	0.012s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	10.149s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.787s
ok  	github.com/carlosframework/platform/internal/status	0.037s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.305s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.007s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.640s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 47251618 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
  minio: downloaded, digest verified
  mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
  mc: downloaded, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.023s
=== RUN   TestT4StorageProbeMinio
2026/10/15 15:55:50 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEBF09CB14E2EE, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.021s
minio contract: PASS
diff --git a/internal/console/seedfloor.go b/internal/console/seedfloor.go
index 4708e8e05..cd1977414 100644
--- a/internal/console/seedfloor.go
+++ b/internal/console/seedfloor.go
@@ -77,7 +77,8 @@ func (s *Server) seedRegionReady(ctx context.Context, region string, oneBox bool
continue
}
prevOK := f.PrevRelease == "none" || f.PrevSeedsAllowedFor == floor.MinRelease
- if f.SeedsFloorMinRelease != floor.MinRelease || !prevOK {
+ rootRefused := (f.MovesFloorState != "" || f.MovesFloorGeneration != 0 || f.SeedsFloorState != "" || f.SeedsFloorGeneration != 0) && (f.SeedsFloorState != "accepted" || f.SeedsFloorGeneration == 0)
+ if rootRefused || f.SeedsFloorMinRelease != floor.MinRelease || !prevOK {
below = append(below, f.Box)
}
}
diff --git a/internal/console/seedfloor_test.go b/internal/console/seedfloor_test.go
index fa06a4e18..11c2ac2df 100644
--- a/internal/console/seedfloor_test.go
+++ b/internal/console/seedfloor_test.go
@@ -352,3 +352,18 @@ func TestSeedsFloorDoorIsForSystemOperators(t *testing.T) {
t.Fatalf("floor = %+v, %v", f, err)
}
}
+
+func TestPlan14SeedsFloorMixedFleet(t *testing.T) {
+ fx := newConsoleFixture(t)
+ publishSeedsFloorFx(t, fx, "v0.27.0")
+ seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "nested", false)
+ for _, extra := range []string{``, `,"moves_floor_state":"accepted"`, `,"moves_floor_state":"accepted","seeds_floor_state":"quarantined","seeds_floor_generation":1`, `,"moves_floor_state":"accepted","seeds_floor_state":"accepted","seeds_floor_generation":1`} {
+ putSeedEdges(t, fx, "kass-2", `{"v":1,"box":"kass-2","eip":"1.2.3.4","hosts":{},"seeds_protocol":1,"seeds_floor_min_release":"v0.27.0","prev_release":"none"`+extra+`}`)
+ putSeedHeartbeat(t, fx, "kass-2", "nested", false, time.Now())
+ ref := fx.srv.seedRegionReady(t.Context(), "", false)
+ accepted := extra == "" || strings.Contains(extra, `"seeds_floor_state":"accepted"`)
+ if (ref == nil) != accepted {
+ t.Fatal("mixed fleet", extra, ref)
+ }
+ }
+}
diff --git a/internal/edgesfile/snapshot.go b/internal/edgesfile/snapshot.go
index 7efff95fb..159505101 100644
--- a/internal/edgesfile/snapshot.go
+++ b/internal/edgesfile/snapshot.go
@@ -78,6 +78,8 @@ type PlacementView struct {
PrevAllowedFor string `json:"prev_allowed_for"`
// The seeds floor (seeded-instances spec §3.3), read only by the
// console's seedRegionReady.
+ SeedsFloorState string `json:"seeds_floor_state,omitempty"`
+ SeedsFloorGeneration uint64 `json:"seeds_floor_generation,omitempty"`
SeedsProtocol int `json:"seeds_protocol"`
SeedsFloorMinRelease string `json:"seeds_floor_min_release"`
PrevSeedsAllowedFor string `json:"prev_seeds_allowed_for"`
diff --git a/internal/host/claimconstruction.go b/internal/host/claimconstruction.go
index b347bacfa..95749026e 100644
--- a/internal/host/claimconstruction.go
+++ b/internal/host/claimconstruction.go
@@ -220,6 +220,10 @@ func OpenProductionClaimAssembly(ctx context.Context, store release.Store, r Cla
return nil, errors.Join(refreshErr, hostwrite.ErrUnavailable)
}
actor = fresh
+ if err := verifyRootMigrationStartup(ctx, rootFloorIdentity{Deployment: actor.ControlBucket, Box: actor.Box, Enrollment: actor.Enrollment}, journals.Inventory); err != nil {
+ launches.Close()
+ return nil, err
+ }
stop, err = owners.claimEvidenceCoordinator(ctx, store, r.Mode, actor)
if err != nil {
launches.Close()
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go
index d8689a643..4f6e753cc 100644
--- a/internal/host/edgesfile.go
+++ b/internal/host/edgesfile.go
@@ -310,6 +310,8 @@ type edgesFile struct {
// (a build that cannot place a seed). SeedsFloorMinRelease is the
// armed seeds floor's min_release (absent: not armed), and
// PrevSeedsAllowedFor the floor carlos.prev was judged at or above.
+ SeedsFloorState string `json:"seeds_floor_state,omitempty"`
+ SeedsFloorGeneration uint64 `json:"seeds_floor_generation,omitempty"`
SeedsProtocol int `json:"seeds_protocol"`
SeedsFloorMinRelease string `json:"seeds_floor_min_release,omitempty"`
PrevSeedsAllowedFor string `json:"prev_seeds_allowed_for,omitempty"`
diff --git a/internal/host/movesfloor.go b/internal/host/movesfloor.go
index 7c38d9b0e..ed25680bb 100644
--- a/internal/host/movesfloor.go
+++ b/internal/host/movesfloor.go
@@ -458,6 +458,11 @@ func rootJudgePrev(reg *registry.Registry) error {
}
func rootJudgePrevWithSource(reg *registry.Registry, source rootFloorIdentitySource) error {
+ if id, required, err := source.read(); err != nil {
+ return err
+ } else if required {
+ return errors.Join(rootJudgeMovesPrevWithSource(reg, source), rootRefreshAndJudgeSeedsPrev(id))
+ }
return errors.Join(rootJudgeMovesPrevWithSource(reg, source), rootJudgeSeedsPrev())
}
diff --git a/internal/host/plan14_root_migration_test.go b/internal/host/plan14_root_migration_test.go
index c2045b0ed..d69f8a6f6 100644
--- a/internal/host/plan14_root_migration_test.go
+++ b/internal/host/plan14_root_migration_test.go
@@ -218,3 +218,63 @@ func TestPlan14RootMigrationCensusCrashResume(t *testing.T) {
}
}
}
+
+func TestPlan14RootMigrationStartupVerification(t *testing.T) {
+ m, _, _ := migrationFixture(t)
+ root := hostguard.NewRootAuthority(&rollbackDisk{dir: t.TempDir()})
+ if err := m.run(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifyRootMigrationState(t.Context(), m.state, m.execution, m.identity, root); err != nil {
+ t.Fatal("matching startup", err)
+ }
+ raw, err := m.state.Read("migration.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, attack := range []string{"missing", "pending", "enrollment", "schema", "census"} {
+ t.Run(attack, func(t *testing.T) {
+ if err := m.state.Write("migration.json", raw); err != nil {
+ t.Fatal(err)
+ }
+ if err := m.census(true); err != nil {
+ t.Fatal(err)
+ }
+ var r rootMigrationRecord
+ if json.Unmarshal(raw, &r) != nil {
+ t.Fatal("fixture")
+ }
+ switch attack {
+ case "missing":
+ if err := m.state.Remove("migration.json"); err != nil {
+ t.Fatal(err)
+ }
+ case "pending":
+ r.Phase = "prepared"
+ case "enrollment":
+ r.Identity.Enrollment = "other"
+ case "schema":
+ r.Schema++
+ case "census":
+ if err := m.census(false); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if attack != "missing" {
+ if err := m.write("migration.json", r); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if verifyRootMigrationState(t.Context(), m.state, m.execution, m.identity, root) == nil {
+ t.Fatal("unsafe startup")
+ }
+ })
+ }
+}
+
+func fixtureWithoutRootMigrationCensus(t *testing.T) {
+ t.Helper()
+ original := openRootMigrationParticipation
+ openRootMigrationParticipation = func() (rootparticipation.Ledger, bool, error) { return rootparticipation.Ledger{}, false, nil }
+ t.Cleanup(func() { openRootMigrationParticipation = original })
+}
diff --git a/internal/host/plan9fix4_claimjournal_migration_linux_test.go b/internal/host/plan9fix4_claimjournal_migration_linux_test.go
index 55b3cee61..1b3a2832b 100644
--- a/internal/host/plan9fix4_claimjournal_migration_linux_test.go
+++ b/internal/host/plan9fix4_claimjournal_migration_linux_test.go
@@ -96,6 +96,7 @@ func fix4JournalFixture(t *testing.T) (release.Store, ClaimRuntimeResources, fix
}
func TestPlan9Fix4RootAssemblyIgnoresReplacedLegacyDirectory(t *testing.T) {
+ fixtureWithoutRootMigrationCensus(t)
for _, mode := range []string{"instance-control", "adopt"} {
for _, replacement := range []string{"missing", "renamed", "symlink", "file"} {
t.Run(mode+"/"+replacement, func(t *testing.T) {
diff --git a/internal/host/relocationsourcehold_linux_test.go b/internal/host/relocationsourcehold_linux_test.go
index acd30ff27..1a127d5ce 100644
--- a/internal/host/relocationsourcehold_linux_test.go
+++ b/internal/host/relocationsourcehold_linux_test.go
@@ -12,6 +12,7 @@ import (
// It sets FenceClosed and HoldRow with CARLOS_REGION_HA on, and neither
// with it off, so reconcile there is main's.
func TestProductionClaimAssemblySetsTheSourceHoldOnlyWithRegionHA(t *testing.T) {
+ fixtureWithoutRootMigrationCensus(t)
for _, v := range []string{"", "0", "1"} {
on := v == "1"
t.Run("CARLOS_REGION_HA="+v, func(t *testing.T) {
diff --git a/internal/host/rootmigration_bootstrap_root_linux_test.go b/internal/host/rootmigration_bootstrap_root_linux_test.go
index 23f13c1fe..336774729 100644
--- a/internal/host/rootmigration_bootstrap_root_linux_test.go
+++ b/internal/host/rootmigration_bootstrap_root_linux_test.go
@@ -15,14 +15,18 @@ import (
"time"
"github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostguard"
"github.com/carlosframework/platform/internal/hostops"
"github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/rootparticipation"
"github.com/carlosframework/platform/internal/rootstate"
)
-func TestPlan14RootMigrationBootstrapRootRunner(t *testing.T) {
+func TestPlan14RootMigrationBootstrapRootRunner(t *testing.T) { testRootMigrationBootstrap(t, false) }
+func TestPlan14RootMigrationHistoricRootRunner(t *testing.T) { testRootMigrationBootstrap(t, true) }
+func testRootMigrationBootstrap(t *testing.T, historic bool) {
if os.Getenv("PLAN14_P3_BOOTSTRAP_CHILD") == "" {
unit := "/usr/lib/systemd/system/carlos-edge.service"
for _, p := range []string{unit, "/etc/systemd/system/carlos-edge.service", "/run/systemd/system/carlos-edge.service"} {
@@ -55,7 +59,7 @@ func TestPlan14RootMigrationBootstrapRootRunner(t *testing.T) {
defer release()
// The existing generation census supports a controlled all-root namespace;
// E owns the wider running-tenant/foreign-namespace production integration.
- cmd := exec.CommandContext(t.Context(), "unshare", "--mount", "--pid", "--fork", "--mount-proc", os.Args[0], "-test.run=^TestPlan14RootMigrationBootstrapRootRunner$", "-test.v")
+ cmd := exec.CommandContext(t.Context(), "unshare", "--mount", "--pid", "--fork", "--mount-proc", os.Args[0], "-test.run=^"+t.Name()+"$", "-test.v")
cmd.Env = append(os.Environ(), "PLAN14_P3_BOOTSTRAP_CHILD=1")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatal(err, string(out))
@@ -64,6 +68,14 @@ func TestPlan14RootMigrationBootstrapRootRunner(t *testing.T) {
}
return
}
+ for _, path := range []string{"/run/carlos", "/run/carlos/hostguard"} {
+ err := os.Mkdir(path, 0755)
+ if err == nil {
+ t.Cleanup(func() { os.RemoveAll(path) })
+ } else if !os.IsExist(err) {
+ t.Fatal(err)
+ }
+ }
rootMigrationQuiesce = func(context.Context) (func(), error) { return func() {}, nil }
for _, path := range []string{"/var/lib/carlos-claims", "/var/lib/carlos-release"} {
if err := os.Mkdir(path, 0700); err != nil {
@@ -100,6 +112,11 @@ func TestPlan14RootMigrationBootstrapRootRunner(t *testing.T) {
key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize))
t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(key.Public().(ed25519.PublicKey)))
plan := hostops.BackfillPlan{V: 1, ID: "migration", Phase: "collecting", DirectoryRevision: 1, CreatedAt: time.Now(), Hosts: map[string]hostops.BackfillHost{}}
+ var target hostguard.FenceTarget
+ var historicBytes []byte
+ if historic {
+ target, historicBytes = migrationHistoricFixture(t, store, r, owners.ClaimOwnerBindings, actor, &plan)
+ }
raw, err := release.SignRootEnvelope(release.RootSigner(key), plan)
if err != nil {
t.Fatal(err)
@@ -131,6 +148,26 @@ func TestPlan14RootMigrationBootstrapRootRunner(t *testing.T) {
oldOwner := claimQuiesceOwner
claimQuiesceOwner = func() (int, int, error) { return 65534, 65534, nil }
defer func() { claimQuiesceOwner = oldOwner }()
+ if historic {
+ held, err := os.Open(filepath.Join(r.DataDir, "historic-data"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = migrateRootClaimAuthority(t.Context(), store, r, owners.ClaimOwnerBindings, actor, service, journal)
+ held.Close()
+ if err == nil {
+ t.Fatal("migration completed with an open historic payload")
+ }
+ if err := r.Registry.UpsertTimer(registry.Timer{Host: target.Host, Name: "unrecorded", Kind: "cron", Spec: "0 0 * * *", Path: "/extra"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := migrateRootClaimAuthority(t.Context(), store, r, owners.ClaimOwnerBindings, actor, service, journal); err == nil {
+ t.Fatal("unrecorded preclaim timer accepted")
+ }
+ if err := r.Registry.DeleteTimer(target.Host, "unrecorded"); err != nil {
+ t.Fatal(err)
+ }
+ }
if err := migrateRootClaimAuthority(t.Context(), store, r, owners.ClaimOwnerBindings, actor, service, journal); err != nil {
t.Fatal("production bootstrap", err)
}
@@ -158,4 +195,35 @@ func TestPlan14RootMigrationBootstrapRootRunner(t *testing.T) {
if err := migrateRootClaimAuthority(t.Context(), store, r, owners.ClaimOwnerBindings, actor, service, journal); err != nil {
t.Fatal("completed migration replay", err)
}
+ if err := verifyRootMigrationStartup(t.Context(), id, service.Inventory); err != nil {
+ t.Fatal("production startup", err)
+ }
+ if historic {
+ fences, err := openClaimRootAuthority()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer fences.Close()
+ root := hostguard.NewRootAuthority(fences)
+ g := hostguard.Guard{Root: root, DataDir: r.DataDir, Claims: hostguard.StoreReader{Store: store}}
+ receipt, err := g.FenceReceiptFor(t.Context(), target)
+ if err != nil || !bytes.Equal(receipt.Bytes, historicBytes) {
+ t.Fatal("historic bytes changed", err)
+ }
+ view, err := root.View(t.Context(), target.Host, hostguard.Captured{Incarnation: target.Incarnation, Stamp: target.Stamp})
+ if err != nil || view.Installation.Artifacts.Timers != "refresh" || len(view.MigrationDenies) != 1 {
+ t.Fatal("historic root inventory", view, err)
+ }
+ if err := os.RemoveAll(filepath.Join(r.DataDir, ".hostclaims")); err != nil {
+ t.Fatal(err)
+ }
+ if err := migrateRootClaimAuthority(t.Context(), store, r, owners.ClaimOwnerBindings, actor, service, journal); err != nil {
+ t.Fatal("legacy erased retry", err)
+ }
+ again, err := g.FenceReceiptFor(t.Context(), target)
+ if err != nil || !bytes.Equal(again.Bytes, historicBytes) || !again.EvidenceAt.Equal(receipt.EvidenceAt) {
+ t.Fatal("historic replay renewed evidence", err)
+ }
+ }
+
}
diff --git a/internal/host/rootmigration_evidence.go b/internal/host/rootmigration_evidence.go
new file mode 100644
index 000000000..b27243cc6
--- /dev/null
+++ b/internal/host/rootmigration_evidence.go
@@ -0,0 +1,64 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostevidence"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func readMigrationStop(ctx context.Context, store release.Store, directory hostactors.Directory, launches *hostbox.LaunchJournal, inventory *hostbox.StopInventoryDB, launch hostbox.LaunchInventory) (StopResult, error) {
+ target := launch.Target
+ in, err := inventory.ReadExactStopInventory(ctx, target)
+ if err != nil {
+ return StopResult{}, err
+ }
+ journal, err := launches.ReadStopKernelJournal(ctx, target)
+ if err != nil {
+ return StopResult{}, err
+ }
+ if err := legacyJournalProof(store, directory)(ctx, launch, in, journal); err != nil {
+ return StopResult{}, err
+ }
+ roster, err := directory.Read(ctx)
+ if err != nil {
+ return StopResult{}, err
+ }
+ actor, ok := roster.Roster.Boxes[target.Box]
+ if !ok || actor.Enrollment != target.Enrollment {
+ return StopResult{}, hostbox.ErrLegacyJournalProof
+ }
+ prefix := strings.TrimSuffix(stopEvidenceKey(target.Host, target.Op, target.Box, ""), ".json")
+ keys, err := store.List(ctx, prefix)
+ if err != nil {
+ return StopResult{}, err
+ }
+ wanted := StopReceipt{Host: target.Host, Incarnation: target.Incarnation, InstallationStamp: target.Stamp, WriterSession: target.Session, Box: target.Box, Enrollment: target.Enrollment, BootID: target.BootID, ControllerID: target.ControllerID, OperationID: target.Op, Region: target.Region, DirectoryRevision: in.DirectoryRevision, InventorySHA256: digestStop(in), LocalJournalSHA256: digestStop(journal)}
+ for _, key := range keys {
+ if !strings.HasPrefix(key, prefix) || !strings.HasSuffix(key, ".json") {
+ continue
+ }
+ raw, _, err := store.Get(ctx, key)
+ if err != nil {
+ return StopResult{}, err
+ }
+ signature, _, err := store.Get(ctx, key+".sig")
+ if err != nil {
+ continue
+ }
+ if hostevidence.Verify(actor.CensusPublicKey, key, raw, signature) != nil {
+ continue
+ }
+ var p StopReceipt
+ if json.Unmarshal(raw, &p) != nil || p.V != 1 || p.Kind != "stopped" || p.ID == "" || p.FenceID == "" || p.ClaimGen == 0 || p.ObservedBootID == "" || p.EvidenceAt.IsZero() || len(p.CgroupIdentities) == 0 || p.RequestLimitNS != int64(hostbox.RequestLimit) || key != prefix+p.ID+".json" || legacyJournalProofMatch(p) != legacyJournalProofMatch(wanted) || p.Phase != target.Phase || p.ObligationID != target.ObligationID {
+ continue
+ }
+ target.FenceID, target.ClaimGen, target.ClaimETag = p.FenceID, p.ClaimGen, p.ClaimETag
+ return StopResult{Target: target, Key: key, Digest: digestRawStop(raw), Bytes: raw, EvidenceAt: p.EvidenceAt}, nil
+ }
+ return StopResult{}, hostbox.ErrLegacyJournalProof
+}
diff --git a/internal/host/rootmigration_historic_linux_test.go b/internal/host/rootmigration_historic_linux_test.go
new file mode 100644
index 000000000..5259aabd0
--- /dev/null
+++ b/internal/host/rootmigration_historic_linux_test.go
@@ -0,0 +1,148 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostevidence"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/hostops"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "golang.org/x/sys/unix"
+)
+
+func migrationHistoricFixture(t *testing.T, store release.Store, r ClaimRuntimeResources, owners ClaimOwnerBindings, actor hostbox.Actor, plan *hostops.BackfillPlan) (hostguard.FenceTarget, []byte) {
+ t.Helper()
+ ctx := t.Context()
+ at := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
+ rec := provision.Record{Schema: 1, Host: "historic.example", Account: "bab", App: "app", Channel: "stable", Region: "ie", CreatedAt: at}
+ raw, _ := json.Marshal(rec)
+ key := provision.RecordKey(rec.Account, rec.App, rec.Host)
+ tag, err := store.Put(ctx, key, raw, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ plan.Hosts[rec.Host] = hostops.BackfillHost{Key: key, ETag: tag}
+ inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, at)
+ if err != nil {
+ t.Fatal(err)
+ }
+ claim := hostclaim.Claim{V: 2, Host: rec.Host, Gen: 1, Holder: &hostclaim.Holder{Family: "instance", Key: key, Account: rec.Account, App: rec.App, CreatedAt: at, Incarnation: inc, Location: "instances/" + rec.Host, Generation: "generation", Stamp: 1, Session: 9, WriterBox: actor.Box, Admissions: []hostclaim.AdmissionRecord{{Region: "ie", Session: 9, Box: actor.Box, Generation: "generation", Mode: "cold", Covered: true}}}, Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: actor.Box, Participants: []string{actor.Box}}}
+ raw, _ = json.Marshal(claim)
+ ckey, _ := hostclaim.Key(rec.Host)
+ ctag, err := store.Put(ctx, ckey, raw, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := hostclaim.Read(ctx, store, rec.Host); err != nil {
+ t.Fatal("claim fixture", err)
+ }
+ target := hostguard.FenceTarget{Host: rec.Host, Incarnation: inc, Stamp: 1, Session: 9, Box: actor.Box, Enrollment: actor.Enrollment, Region: "ie", DirectoryRevision: "1", BootID: "historic-boot", ControllerID: "historic-controller", ClaimGen: 1, ClaimETag: ctag, FenceID: "historic-fence"}
+ if err := r.Registry.UpsertTimer(registry.Timer{Host: rec.Host, Name: "refresh", Kind: "cron", Spec: "0 0 * * *", Path: "/refresh"}); err != nil {
+ t.Fatal(err)
+ }
+ legacy := filepath.Join(r.DataDir, ".hostclaims")
+ launches, err := hostbox.OpenLaunchJournal(filepath.Join(legacy, "launches.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ launch := hostbox.LaunchInventory{Target: target, ControllerPID: 999999, ProcessStart: "1", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "historic-session", Device: 1, Inode: 2}}}
+ if err := launches.Create(ctx, launch); err != nil {
+ t.Fatal(err)
+ }
+ if err := launches.CloseWriters(ctx, target); err != nil {
+ t.Fatal(err)
+ }
+ journal, err := launches.ReadStopKernelJournal(ctx, target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := launches.Close(); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(r.DataDir, "historic-data")
+ if err := os.WriteFile(path, []byte("retained tenant bytes"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ var st unix.Stat_t
+ if err := unix.Lstat(path, &st); err != nil {
+ t.Fatal(err)
+ }
+ in := hostbox.StopInventory{Complete: true, Host: rec.Host, Incarnation: inc, InstallationStamp: 1, Box: actor.Box, Enrollment: actor.Enrollment, DirectoryRevision: "1", Entries: []hostbox.StopInventoryEntry{{Kind: "path", ArtifactID: "historic-data", ManagedRoot: "data", RelativePath: "historic-data", Ownership: "historic-owner", Materialization: "materialized", Device: uint64(st.Dev), Inode: st.Ino, Type: st.Mode & unix.S_IFMT}, {Kind: "timer", ArtifactID: "timer", ManagedRoot: "timers", RelativePath: rec.Host + "/refresh", Ownership: "historic-owner", Materialization: "materialized"}}}
+ inventory, err := hostbox.OpenStopInventoryDB(filepath.Join(legacy, "inventory.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := inventory.WriteStopInventory(ctx, in); err != nil {
+ t.Fatal(err)
+ }
+ if err := inventory.Close(); err != nil {
+ t.Fatal(err)
+ }
+ receipt := StopReceipt{V: 1, ID: "historic-stop", Kind: "stopped", Host: target.Host, Incarnation: inc, InstallationStamp: 1, WriterSession: 9, Box: actor.Box, Enrollment: actor.Enrollment, Region: "ie", BootID: target.BootID, ObservedBootID: target.BootID, ControllerID: target.ControllerID, DirectoryRevision: "1", ClaimGen: 1, ClaimETag: ctag, FenceID: target.FenceID, EvidenceAt: at, RequestLimitNS: int64(hostbox.RequestLimit), InventorySHA256: digestStop(in), LocalJournalSHA256: digestStop(journal), CgroupIdentities: []string{"historic-session"}}
+ raw, _ = json.Marshal(receipt)
+ if err := hostevidence.Publish(ctx, store, owners.EvidenceKey, stopEvidenceKey(target.Host, "", actor.Box, receipt.ID), raw); err != nil {
+ t.Fatal(err)
+ }
+ return target, raw
+}
+
+func TestPlan14RootMigrationHistoricEvidence(t *testing.T) {
+ store, r, owners, _ := fix4JournalFixture(t)
+ actor, err := owners.Identity.Verify(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ plan := hostops.BackfillPlan{Hosts: map[string]hostops.BackfillHost{}}
+ target, raw := migrationHistoricFixture(t, store, r, owners.ClaimOwnerBindings, actor, &plan)
+ j, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "root.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j.Close()
+ service, err := hostbox.NewJournalService(j)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := service.MigrateLegacy(t.Context(), filepath.Join(r.DataDir, ".hostclaims"), actor.Box, actor.Enrollment, func(context.Context) (func(), error) { return func() {}, nil }, legacyJournalProof(store, owners.RetirementAuthority.Directory)); err != nil {
+ t.Fatal("signed legacy import", err)
+ }
+ launch, err := j.Read(t.Context(), target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, err := readMigrationStop(t.Context(), store, owners.RetirementAuthority.Directory, j, service.Inventory, launch)
+ if err != nil || string(result.Bytes) != string(raw) {
+ t.Fatal("exact historic evidence", err)
+ }
+ signatureKey := result.Key + ".sig"
+ validSignature, signatureETag, err := store.Get(t.Context(), signatureKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ badETag, err := store.Put(t.Context(), signatureKey, []byte("forged"), signatureETag)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := readMigrationStop(t.Context(), store, owners.RetirementAuthority.Directory, j, service.Inventory, launch); err == nil {
+ t.Fatal("forged historic signature accepted")
+ }
+ if _, err := store.Put(t.Context(), signatureKey, validSignature, badETag); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := readMigrationStop(t.Context(), store, owners.RetirementAuthority.Directory, j, service.Inventory, launch); err != nil {
+ t.Fatal("valid evidence after refusal", err)
+ }
+ launch.Target.Session++
+ if _, err := readMigrationStop(t.Context(), store, owners.RetirementAuthority.Directory, j, service.Inventory, launch); err == nil {
+ t.Fatal("foreign session evidence")
+ }
+}
diff --git a/internal/host/rootmigration_production_linux.go b/internal/host/rootmigration_production_linux.go
index 655f48b1c..7dfe54830 100644
--- a/internal/host/rootmigration_production_linux.go
+++ b/internal/host/rootmigration_production_linux.go
@@ -22,8 +22,6 @@ import (
var rootMigrationQuiesce = quiesceLegacyClaimJournals
-const rootMigrationDir = "/var/lib/carlos-claims/authority/migration"
-
func migrateRootClaimAuthority(ctx context.Context, store release.Store, r ClaimRuntimeResources, owners ClaimOwnerBindings, actor hostbox.Actor, journals *hostbox.JournalService, launches *hostbox.LaunchJournal) error {
return withClaimRootRelease(ctx, true, func() (rootstate.Store, error) { return rootstate.Open(rootReleasePrivate, true, false) }, func() error {
state, err := rootstate.Open(rootMigrationDir, true, false)
@@ -76,7 +74,10 @@ func migrateRootClaimAuthority(ctx context.Context, store release.Store, r Claim
if err != nil {
return err
}
- artifacts, err := rootInventoryArtifacts(item.Installation, canonical, in, revision)
+ prior := item.Installation
+ // The signed complete inventory plus the offline physical census establishes this history.
+ prior.Artifacts.Schema = 1
+ artifacts, err := rootInventoryArtifacts(prior, canonical, in, revision)
if err != nil {
return err
}
@@ -100,8 +101,15 @@ func migrateRootClaimAuthority(ctx context.Context, store release.Store, r Claim
if target.Host != in.Host || target.Incarnation != in.Incarnation || target.Stamp != in.Stamp || target.Session != item.Session {
continue
}
+ if err := verifyMigrationStopped(ctx, launches, inventory, l, roots); err != nil {
+ return err
+ }
+ result, err := readMigrationStop(ctx, store, owners.RetirementAuthority.Directory, launches, inventory, l)
+ if err != nil {
+ return err
+ }
+ target = result.Target
target.ClaimETag = item.ClaimETag
- target.FenceID = "migration-" + generationInventoryDigest(item)
target, err = guard.BeginFence(ctx, target)
if err != nil {
return err
@@ -116,10 +124,10 @@ func migrateRootClaimAuthority(ctx context.Context, store release.Store, r Claim
} else if !errors.Is(err, hostguard.ErrNoFenceReceipt) {
return err
}
- result, err := coordinator.RecoverStopped(ctx, target)
- if err != nil {
+ if err := guard.CompleteFence(ctx, target, result.Bytes, result.EvidenceAt, func(context.Context, hostguard.FenceTarget, []byte, time.Time) error { return nil }); err != nil {
return err
}
+ result.Target = target
if err := coordinator.Publish(ctx, result); err != nil {
return err
}
@@ -127,6 +135,9 @@ func migrateRootClaimAuthority(ctx context.Context, store release.Store, r Claim
}
return ErrClaimStopInventory
}, ready: func(ctx context.Context, s rootMigrationSnapshot) error {
+ if err := verifyRootMigrationRetained(ctx, root, s); err != nil {
+ return err
+ }
var all []hostbox.StopInventory
for _, item := range s.Items {
in, revision, err := inventory.Snapshot(ctx, item.Installation.Host, hostguard.Captured{Incarnation: item.Installation.Incarnation, Stamp: item.Installation.Stamp})
@@ -142,6 +153,9 @@ func migrateRootClaimAuthority(ctx context.Context, store release.Store, r Claim
if revision == 0 || retained.InventoryRevision != revision || !in.Complete {
return ErrClaimStopInventory
}
+ if err := (ClaimRootHTTP{Reconcile: hostbox.ReconcileAdapter{Registry: r.Registry}}).verifyInventoryTimers(in); err != nil {
+ return err
+ }
all = append(all, in)
}
if err := hostbox.CheckMigrationRoots(ctx, roots, claimGlobalSharedPaths(r), all); err != nil {
@@ -153,25 +167,15 @@ func migrateRootClaimAuthority(ctx context.Context, store release.Store, r Claim
if _, _, err := productionGenerationKernelCensus(ctx); err != nil {
return err
}
- floors, err := openRootMovesFloors(r.OptDir, rootFloorIdentity{Deployment: id.Deployment, Box: id.Box, Enrollment: id.Enrollment}, false)
- if err != nil {
- return err
- }
- defer floors.close()
- p, err := floors.projection()
- if err != nil {
- return err
- }
- for _, path := range []string{systemBinaryPath(r.OptDir), filepath.Join(systemGuardDir(r.OptDir), "carlos")} {
- b, err := readRootFloorBinary(r.OptDir, path)
+ floorID := rootFloorIdentity{Deployment: id.Deployment, Box: id.Box, Enrollment: id.Enrollment}
+ for _, dir := range []string{rootFloorDir(r.OptDir), rootSeedsFloorDir(r.OptDir)} {
+ f, err := openRootProtocolFloors(dir, floorID, true)
if err != nil {
return err
}
- if !b.supports(p.Floor) {
- return errRootFloorHistory
- }
+ f.close()
}
- return nil
+ return verifyRootMigrationFloors(r.OptDir, rootFloorIdentity{Deployment: id.Deployment, Box: id.Box, Enrollment: id.Enrollment})
}, publish: func(ctx context.Context, report hostops.CensusReport) error {
return hostops.PublishCensusReport(ctx, store, owners.RetirementAuthority.Directory, report, owners.EvidenceKey)
}}
diff --git a/internal/host/rootmigration_reboot_root_linux_test.go b/internal/host/rootmigration_reboot_root_linux_test.go
new file mode 100644
index 000000000..d92ddd015
--- /dev/null
+++ b/internal/host/rootmigration_reboot_root_linux_test.go
@@ -0,0 +1,150 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/hostops"
+ "github.com/carlosframework/platform/internal/rootparticipation"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+const migrationRebootDir = "/var/lib/plan14-p3-migration-reboot"
+
+func rebootMigration(t *testing.T) (rootMigration, *hostguard.RootAuthority) {
+ t.Helper()
+ open := func(name string) rootstate.Store {
+ s, e := rootstate.Open(filepath.Join(migrationRebootDir, name), true, false)
+ if e != nil {
+ t.Fatal(e)
+ }
+ t.Cleanup(func() { s.Close() })
+ return s
+ }
+ state, execution, fences := open("migration"), open("execution"), open("fences")
+ root := hostguard.NewRootAuthority(fences)
+ m := rootMigration{state: state, execution: execution, identity: rootMigrationIdentity{"deploy", "box", "en", 1}, quiesce: func(context.Context) (func(), error) { return func() {}, nil }}
+ m.observe = func(context.Context) (rootMigrationSnapshot, error) {
+ raw, e := state.Read("snapshot.json")
+ var s rootMigrationSnapshot
+ if e == nil {
+ e = json.Unmarshal(raw, &s)
+ }
+ return s, e
+ }
+ m.deny = func(ctx context.Context, item rootMigrationItem) error {
+ return root.RetainMigrationDeny(ctx, item.Installation, item.Session)
+ }
+ m.recover = func(ctx context.Context, item rootMigrationItem) error {
+ return verifyRootMigrationRetained(ctx, root, rootMigrationSnapshot{Items: []rootMigrationItem{item}})
+ }
+ m.ready = func(ctx context.Context, s rootMigrationSnapshot) error {
+ return verifyRootMigrationRetained(ctx, root, s)
+ }
+ m.publish = func(context.Context, hostops.CensusReport) error { return nil }
+ return m, root
+}
+func TestRootRebootPlan14MigrationPhase1(t *testing.T) {
+ if err := os.Mkdir(migrationRebootDir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ m, root := rebootMigration(t)
+ _, s, _ := migrationFixture(t)
+ item := rootMigrationItem{Installation: hostguard.RootInstallation{Host: "a.example", Incarnation: "inc", Stamp: 1, Account: "bab", App: "app", Record: "record", Box: "box", Enrollment: "en", Migration: 1, InventoryRevision: 1, Artifacts: hostguard.InstallationArtifacts{Schema: 1}}, Session: 9, ClaimETag: "claim"}
+ s.Items = []rootMigrationItem{item}
+ if err := m.write("snapshot.json", s); err != nil {
+ t.Fatal(err)
+ }
+ // This phase-engine witness starts with a root-verified completed stop. The
+ // separate historic runner exercises its signed import and physical census.
+ if err := root.RetainMigrationDeny(t.Context(), item.Installation, item.Session); err != nil {
+ t.Fatal(err)
+ }
+ target := hostguard.FenceTarget{Host: "a.example", Incarnation: "inc", Stamp: 1, Session: 9, Box: "box", Enrollment: "en", BootID: "old", ControllerID: "controller", ClaimETag: "claim", DirectoryRevision: "7", FenceID: "fence"}
+ fences, err := rootstate.Open(filepath.Join(migrationRebootDir, "fences"), false, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer fences.Close()
+ raw, err := fences.Read("a.example.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var record map[string]any
+ if json.Unmarshal(raw, &record) != nil {
+ t.Fatal("record")
+ }
+ // Encode the typed completed receipt as pre-existing authority for this crash fixture.
+ var state map[string]any
+ encoded, _ := json.Marshal(record["State"])
+ if json.Unmarshal(encoded, &state) != nil {
+ t.Fatal("state")
+ }
+ state["fence_journal"] = []hostguard.FenceReceipt{{Target: target, Bytes: []byte("root verified receipt"), EvidenceAt: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)}}
+ record["State"] = state
+ raw, _ = json.Marshal(record)
+ if err := fences.Write("a.example.json", raw); err != nil {
+ t.Fatal(err)
+ }
+ boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := m.state.Write("boot", boot); err != nil {
+ t.Fatal(err)
+ }
+ cuts := 0
+ interrupted := errors.New("completion parent sync interrupted")
+ m.state = rootstate.WithWriteFault(m.state, func(name, stage string, op func() error) error {
+ if filepath.Base(name) == "migration.json" && stage == "parent-sync" {
+ cuts++
+ if cuts == 4 {
+ return interrupted
+ }
+ }
+ return op()
+ })
+ if err := m.run(t.Context()); !errors.Is(err, interrupted) {
+ t.Fatal("crash boundary", cuts, err)
+ }
+ if _, err := (rootparticipation.Ledger{State: m.execution}).Census(); err == nil {
+ t.Fatal("unsynced completion allowed grants")
+ }
+}
+func TestRootRebootPlan14MigrationPhase2(t *testing.T) {
+ if _, err := os.Stat(migrationRebootDir); err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(migrationRebootDir)
+ m, root := rebootMigration(t)
+ before, err := m.state.Read("boot")
+ if err != nil {
+ t.Fatal(err)
+ }
+ now, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
+ if err != nil || strings.TrimSpace(string(before)) == strings.TrimSpace(string(now)) {
+ t.Fatal("no real reboot", err)
+ }
+ if err := m.run(t.Context()); err != nil {
+ t.Fatal("reboot resume", err)
+ }
+ if err := verifyRootMigrationState(t.Context(), m.state, m.execution, m.identity, root); err != nil {
+ t.Fatal("startup verification", err)
+ }
+ v, err := root.View(t.Context(), "a.example", hostguard.Captured{Incarnation: "inc", Stamp: 1})
+ if err != nil || len(v.MigrationDenies) != 1 || v.MigrationDenies[0].Session != 9 || len(v.Receipts) != 1 || string(v.Receipts[0].Bytes) != "root verified receipt" {
+ t.Fatal("reboot lost evidence", v, err)
+ }
+ if err := m.run(t.Context()); err != nil {
+ t.Fatal("completed reboot retry", err)
+ }
+}
diff --git a/internal/host/rootmigration_startup.go b/internal/host/rootmigration_startup.go
new file mode 100644
index 000000000..fce08ca96
--- /dev/null
+++ b/internal/host/rootmigration_startup.go
@@ -0,0 +1,127 @@
+package host
+
+import (
+ "context"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rootparticipation"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+const rootMigrationDir = "/var/lib/carlos-claims/authority/migration"
+
+var openRootMigrationParticipation = rootparticipation.OpenForUpdate
+
+func verifyRootMigrationRetained(ctx context.Context, root *hostguard.RootAuthority, snapshot rootMigrationSnapshot) error {
+ views, err := root.MigrationInstallations(ctx)
+ if err != nil {
+ return err
+ }
+ byHost := map[string]hostguard.FenceView{}
+ for _, v := range views {
+ byHost[v.Host] = v
+ }
+ for _, item := range snapshot.Items {
+ v, ok := byHost[item.Installation.Host]
+ if !ok {
+ return errRootMigrationBusy
+ }
+ in := item.Installation
+ denied, stopped := false, false
+ for _, d := range v.MigrationDenies {
+ if d.Incarnation == in.Incarnation && d.Stamp >= in.Stamp && d.Session >= item.Session {
+ denied = true
+ }
+ }
+ for _, r := range v.Receipts {
+ if r.Target.Host == in.Host && r.Target.Incarnation == in.Incarnation && r.Target.Stamp == in.Stamp && r.Target.Session == item.Session && r.Target.Box == in.Box && r.Target.Enrollment == in.Enrollment && len(r.Bytes) > 0 && !r.EvidenceAt.IsZero() {
+ stopped = true
+ }
+ }
+ if !denied || !stopped || v.Installation.InventoryRevision == 0 || v.Installation.Artifacts.Schema != 1 {
+ return errRootMigrationBusy
+ }
+ }
+ return nil
+}
+func verifyRootMigrationState(ctx context.Context, state, execution rootstate.Store, id rootMigrationIdentity, root *hostguard.RootAuthority) error {
+ c, err := (rootparticipation.Ledger{State: execution}).Census()
+ if err != nil {
+ return err
+ }
+ if c.Box != id.Box || c.Enrollment != id.Enrollment || c.Migration != id.Generation || !c.Complete || c.NeverEnrolled {
+ return errRootMigrationBusy
+ }
+ raw, err := state.Read("migration.json")
+ if err != nil {
+ return err
+ }
+ var r rootMigrationRecord
+ if rootparticipation.Decode(raw, &r) != nil || r.Schema != 1 || r.Identity != id || r.Phase != "complete" || r.Snapshot.preflight(id) != nil {
+ return errRootMigrationBusy
+ }
+ return verifyRootMigrationRetained(ctx, root, r.Snapshot)
+}
+func verifyRootMigrationStartup(ctx context.Context, id rootFloorIdentity, inventory *hostbox.StopInventoryDB) error {
+ l, required, err := openRootMigrationParticipation()
+ if l.State != nil {
+ defer l.State.Close()
+ }
+ if err != nil || !required {
+ return err
+ }
+ c, err := l.Census()
+ if err != nil {
+ return err
+ }
+ state, err := rootstate.Open(rootMigrationDir, false, false)
+ if err != nil {
+ return err
+ }
+ defer state.Close()
+ fences, err := openClaimRootAuthority()
+ if err != nil {
+ return err
+ }
+ defer fences.Close()
+ root := hostguard.NewRootAuthority(fences)
+ if err := verifyRootMigrationState(ctx, state, l.State, rootMigrationIdentity{Deployment: id.Deployment, Box: id.Box, Enrollment: id.Enrollment, Generation: c.Migration}, root); err != nil {
+ return err
+ }
+ views, err := root.MigrationInstallations(ctx)
+ if err != nil {
+ return err
+ }
+ for _, v := range views {
+ if inventory == nil {
+ return errRootMigrationBusy
+ }
+ in, revision, err := inventory.Snapshot(ctx, v.Host, hostguard.Captured{Incarnation: v.Installation.Incarnation, Stamp: v.Installation.Stamp})
+ if err != nil || !in.Complete || revision == 0 || revision != v.Installation.InventoryRevision || v.Installation.Box != id.Box || v.Installation.Enrollment != id.Enrollment {
+ return errRootMigrationBusy
+ }
+ }
+ return verifyRootMigrationFloors(release.OptDir(), id)
+}
+
+func verifyRootMigrationFloors(opt string, id rootFloorIdentity) error {
+ bound, err := readRootProtocolBound(rootFloorDir(opt), id)
+ if err != nil {
+ return err
+ }
+ for _, path := range []string{systemBinaryPath(opt), systemGuardDir(opt) + "/carlos"} {
+ b, err := readRootFloorBinary(opt, path)
+ if err != nil {
+ return err
+ }
+ if !b.supports(bound) {
+ return errRootFloorHistory
+ }
+ if err := rootSeedsAllowsBinary(opt, id, path); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/host/rootmigration_stopped_linux.go b/internal/host/rootmigration_stopped_linux.go
new file mode 100644
index 000000000..0defc4a04
--- /dev/null
+++ b/internal/host/rootmigration_stopped_linux.go
@@ -0,0 +1,46 @@
+package host
+
+import (
+ "context"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "os"
+ "strings"
+)
+
+func verifyMigrationStopped(ctx context.Context, launches *hostbox.LaunchJournal, inventory *hostbox.StopInventoryDB, launch hostbox.LaunchInventory, roots map[string]string) error {
+ if os.Geteuid() != 0 {
+ return ErrClaimStopUnavailable
+ }
+ if err := verifyStopKernelMounts("/sys/fs/cgroup", "/proc"); err != nil {
+ return err
+ }
+ journal, err := launches.ReadStopKernelJournal(ctx, launch.Target)
+ if err != nil {
+ return err
+ }
+ if !journal.WritersClosed || len(journal.ProducerIDs) != 0 || journal.PendingRoles || !journal.Settled {
+ return ErrClaimStopInventory
+ }
+ raw, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
+ if err != nil {
+ return err
+ }
+ boot := strings.TrimSpace(string(raw))
+ if boot == launch.Target.BootID {
+ _, err = scanStopCgroups(ctx, "/sys/fs/cgroup", journal.Cgroups)
+ } else {
+ _, err = scanRebootCgroups(ctx, "/sys/fs/cgroup", journal.Cgroups, launch.Target.BootID, boot)
+ }
+ if err != nil {
+ return err
+ }
+ in, err := inventory.ReadExactStopInventory(ctx, launch.Target)
+ if err != nil {
+ return err
+ }
+ paths, err := (LinuxStopKernel{ManagedRoots: roots}).stopDescriptorPaths(in)
+ if err != nil {
+ return err
+ }
+ return scanStopDescriptors(ctx, "/proc", journal.Files, paths)
+}
diff --git a/internal/host/rootmovesfloor.go b/internal/host/rootmovesfloor.go
index 2e9adcf9f..993dccac7 100644
--- a/internal/host/rootmovesfloor.go
+++ b/internal/host/rootmovesfloor.go
@@ -50,13 +50,16 @@ type rootMovesFloors struct {
func rootFloorDir(opt string) string { return filepath.Join(opt, "system", "guard", "moves") }
func openRootMovesFloors(opt string, id rootFloorIdentity, create bool) (rootMovesFloors, error) {
+ return openRootProtocolFloors(rootFloorDir(opt), id, create)
+}
+func openRootProtocolFloors(dir string, id rootFloorIdentity, create bool) (rootMovesFloors, error) {
f := rootMovesFloors{identity: id}
var err error
- f.public, err = rootstate.Open(rootFloorDir(opt), create, true)
+ f.public, err = rootstate.Open(dir, create, true)
if err != nil {
return f, err
}
- f.private, err = rootstate.Open(filepath.Join(rootFloorDir(opt), "private"), create, false)
+ f.private, err = rootstate.Open(filepath.Join(dir, "private"), create, false)
if err != nil {
f.public.Close()
return rootMovesFloors{}, err
diff --git a/internal/host/rootmovesfloor_boot.go b/internal/host/rootmovesfloor_boot.go
index 3bad40e3a..203709984 100644
--- a/internal/host/rootmovesfloor_boot.go
+++ b/internal/host/rootmovesfloor_boot.go
@@ -68,6 +68,9 @@ func rootFloorAllowsPrev(opt string) error {
return err
}
defer f.close()
+ if err := rootSeedsAllowsBinary(opt, p.Identity, systemPrevPath(opt)); err != nil {
+ return err
+ }
return rootFloorPrevVerdict(rootFloorJudge{floors: f, prev: systemPrevPath(opt), binary: func(path string) (rootFloorBinary, error) { return readRootFloorBinary(opt, path) }})
}
@@ -84,6 +87,16 @@ func withRootFloorGuardCandidate(ctx context.Context, opt string, id rootFloorId
return err
}
defer unlock()
+ seeds, err := openRootProtocolFloors(rootSeedsFloorDir(opt), id, true)
+ if err != nil {
+ return err
+ }
+ defer seeds.close()
+ unlockSeeds, err := seeds.private.Lock(ctx, "floor.lock")
+ if err != nil {
+ return err
+ }
+ defer unlockSeeds()
j := rootFloorJudge{floors: f, current: systemBinaryPath(opt), prev: systemPrevPath(opt), binary: func(path string) (rootFloorBinary, error) { return readRootFloorBinary(opt, path) }}
candidate, err := rootFloorBootCandidate(ctx, j, journal, func(ctx context.Context, path string) error {
_, err := productionSysOps().run(ctx, systemVersionWait, path, "system", "floor-allows-prev", "--root", opt)
@@ -92,6 +105,15 @@ func withRootFloorGuardCandidate(ctx context.Context, opt string, id rootFloorId
if err != nil {
return err
}
+ if err := rootSeedsAllowsBinary(opt, id, candidate); err != nil {
+ candidate = j.current
+ if _, err := rootFloorBootCandidate(ctx, j, false, nil, nil); err != nil {
+ return err
+ }
+ if err := rootSeedsAllowsBinary(opt, id, candidate); err != nil {
+ return err
+ }
+ }
return effect(candidate)
})
})
diff --git a/internal/host/rootmovesfloor_boot_root_linux_test.go b/internal/host/rootmovesfloor_boot_root_linux_test.go
index 528352643..a47102c3c 100644
--- a/internal/host/rootmovesfloor_boot_root_linux_test.go
+++ b/internal/host/rootmovesfloor_boot_root_linux_test.go
@@ -20,6 +20,7 @@ import (
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/rootstate"
+ "github.com/carlosframework/platform/internal/seeds"
)
func TestPlan14RootFloorBootSelectors(t *testing.T) {
@@ -45,7 +46,7 @@ func TestPlan14RootFloorBootSelectors(t *testing.T) {
t.Fatal(err)
}
for _, unit := range []string{"carlos-system-update.service", "carlos-system-recover.service"} {
- for _, attack := range []string{"none", "below", "stale verdict", "missing floor", "corrupt floor", "old reader", "no guard", "missing accepted", "crash-1", "crash-2", "crash-3", "crash-4", "crash-5"} {
+ for _, attack := range []string{"none", "seeds accepted", "seeds below", "seeds legacy", "below", "stale verdict", "missing floor", "corrupt floor", "old reader", "no guard", "missing accepted", "crash-1", "crash-2", "crash-3", "crash-4", "crash-5"} {
t.Run(unit+"/"+attack, func(t *testing.T) {
opt, err := os.MkdirTemp(dir, "box-")
if err != nil {
@@ -86,6 +87,35 @@ func TestPlan14RootFloorBootSelectors(t *testing.T) {
if err := j.judge(t.Context()); err != nil {
t.Fatal(err)
}
+ if strings.HasPrefix(attack, "seeds ") {
+ sf, err := openRootProtocolFloors(rootSeedsFloorDir(opt), id, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer sf.close()
+ floor := moves.Floor{Min: 1, MinRelease: "v2.0.0"}
+ if attack != "seeds accepted" {
+ floor.MinRelease = "v3.0.0"
+ }
+ if attack == "seeds legacy" {
+ dd := filepath.Join(opt, "data")
+ if err := os.MkdirAll(dd, 0755); err != nil {
+ t.Fatal(err)
+ }
+ armLocalSeedsFloor(t, dd, seeds.Floor{Min: floor.Min, MinRelease: floor.MinRelease})
+ if err := refreshRootSeedsFloor(t.Context(), release.NewDirStore(t.TempDir()), opt, dd, id); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.RemoveAll(dd); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := sf.projection(); err == nil {
+ t.Fatal("legacy seeds accepted")
+ }
+ } else if err := sf.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &floor, Source: "root history", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, sf)}); err != nil {
+ t.Fatal(err)
+ }
+ }
switch attack {
case "below":
makeBinary(systemBinaryPath(opt), "v1.0.0", true)
@@ -171,7 +201,7 @@ func TestPlan14RootFloorBootSelectors(t *testing.T) {
}
out, runErr := exec.CommandContext(t.Context(), "systemctl", "start", name).CombinedOutput()
got, readErr := os.ReadFile(mark)
- if attack == "none" || attack == "no guard" || attack == "missing accepted" || attack == "missing floor" || attack == "corrupt floor" || attack == "crash-1" {
+ if attack == "none" || attack == "seeds accepted" || attack == "no guard" || attack == "missing accepted" || attack == "missing floor" || attack == "corrupt floor" || attack == "crash-1" {
if runErr != nil || readErr != nil || string(got) != "v2.0.0" {
journal, _ := exec.Command("journalctl", "-u", name, "--no-pager").CombinedOutput()
t.Fatalf("positive: %v %s %v %s\n%s", runErr, out, readErr, got, journal)
@@ -216,6 +246,9 @@ func TestPlan14RootFloorBootPayload(t *testing.T) {
if err != nil {
t.Fatal(err)
}
+ if err := rootSeedsAllowsBinary(opt, id, path); err != nil {
+ t.Fatal(err)
+ }
if out, err := exec.CommandContext(t.Context(), path, "system", "apply").CombinedOutput(); err != nil {
t.Fatal(err, string(out))
}
@@ -233,7 +266,7 @@ func rootFloorSignRunnerBytes(t *testing.T, opt, path, version string, raw []byt
defer s.Close()
sum := sha256.Sum256(raw)
platform := runtime.GOOS + "-" + runtime.GOARCH
- p := release.SystemPointer{Schema: 1, Component: "router", Channel: "stable", Spread: "0s", PromotedAt: time.Now(), Current: release.SystemRelease{Version: version, ReleaseTag: version, MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{platform: {Key: release.SystemArtifactKey(version, platform), SHA256: hex.EncodeToString(sum[:])}}}}
+ p := release.SystemPointer{Schema: 1, Component: "router", Channel: "stable", Spread: "0s", PromotedAt: time.Now(), Current: release.SystemRelease{Version: version, ReleaseTag: version, MovesProtocol: 1, SeedsProtocol: 1, Artifacts: map[string]release.SystemArtifact{platform: {Key: release.SystemArtifactKey(version, platform), SHA256: hex.EncodeToString(sum[:])}}}}
wire, err := release.SignRootEnvelope(release.RootSigner(key), p)
if err != nil {
t.Fatal(err)
diff --git a/internal/host/rootmovesfloor_crash_root_linux_test.go b/internal/host/rootmovesfloor_crash_root_linux_test.go
index 32bb39ecb..318cfe55d 100644
--- a/internal/host/rootmovesfloor_crash_root_linux_test.go
+++ b/internal/host/rootmovesfloor_crash_root_linux_test.go
@@ -12,13 +12,19 @@ import (
)
func TestPlan14RootFloorSyscallCrashOrdering(t *testing.T) {
+ testRootProtocolFloorCrashes(t, rootFloorDir)
+}
+func TestPlan14RootSeedsFloorSyscallCrashOrdering(t *testing.T) {
+ testRootProtocolFloorCrashes(t, rootSeedsFloorDir)
+}
+func testRootProtocolFloorCrashes(t *testing.T, floorDir func(string) string) {
for _, stage := range []string{"write", "chmod", "file-sync", "close", "rename", "parent-sync"} {
for _, after := range []bool{false, true} {
for cut := 1; cut <= 5; cut++ {
t.Run(fmt.Sprintf("%s/after=%t/write=%d", stage, after, cut), func(t *testing.T) {
opt := generationRootRunnerDir(t)
id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"}
- f, err := openRootMovesFloors(opt, id, true)
+ f, err := openRootProtocolFloors(floorDir(opt), id, true)
if err != nil {
t.Fatal(err)
}
@@ -51,7 +57,7 @@ func TestPlan14RootFloorSyscallCrashOrdering(t *testing.T) {
t.Fatalf("cut not reached: %v", err)
}
f.close()
- f, err = openRootMovesFloors(opt, id, false)
+ f, err = openRootProtocolFloors(floorDir(opt), id, false)
if err != nil {
t.Fatal(err)
}
@@ -66,13 +72,13 @@ func TestPlan14RootFloorSyscallCrashOrdering(t *testing.T) {
if bVisible && (bound == nil || *bound != b) {
t.Fatalf("lost visible deny: %+v", bound)
}
- if p, e := readRootFloorProjection(opt); e == nil && p.Floor != nil && *p.Floor == b {
+ if p, e := readRootProtocolProjection(floorDir(opt)); e == nil && p.Floor != nil && *p.Floor == b {
t.Fatal("failed syscall acknowledged B")
}
if err := f.refresh(t.Context(), &b, nil, nil); err != nil {
t.Fatal("retry", err)
}
- p, err := readRootFloorProjection(opt)
+ p, err := readRootProtocolProjection(floorDir(opt))
if err != nil || p.Floor == nil || *p.Floor != b {
t.Fatal("retry did not durably accept B", p, err)
}
diff --git a/internal/host/rootmovesfloor_refresh.go b/internal/host/rootmovesfloor_refresh.go
index 2c9444d3a..bf2044a2d 100644
--- a/internal/host/rootmovesfloor_refresh.go
+++ b/internal/host/rootmovesfloor_refresh.go
@@ -19,9 +19,9 @@ import (
)
type rootFloorBinary struct {
- Version, Digest string
- Protocol int
- File rootstate.FileIdentity
+ Version, Digest string
+ Protocol, SeedsProtocol int
+ File rootstate.FileIdentity
}
type rootFloorVerdict struct {
Identity rootFloorIdentity
@@ -190,7 +190,7 @@ func readRootFloorBinary(opt, path string) (rootFloorBinary, error) {
return b, err
}
if p.Current.Artifacts[runtime.GOOS+"-"+runtime.GOARCH].SHA256 == b.Digest {
- b.Version, b.Protocol = p.Current.ReleaseTag, p.Current.MovesProtocol
+ b.Version, b.Protocol, b.SeedsProtocol = p.Current.ReleaseTag, p.Current.MovesProtocol, p.Current.SeedsProtocol
return b, nil
}
}
diff --git a/internal/host/rootmovesfloor_source.go b/internal/host/rootmovesfloor_source.go
index 35fa1c865..06386d6e3 100644
--- a/internal/host/rootmovesfloor_source.go
+++ b/internal/host/rootmovesfloor_source.go
@@ -48,9 +48,13 @@ func readEffectiveMovesFloor(source rootFloorIdentitySource, opt, dd string) (*m
if !required {
return readLocalFloor(dd)
}
- f, err := openRootMovesFloors(opt, id, false)
+ return readRootProtocolBound(rootFloorDir(opt), id)
+}
+
+func readRootProtocolBound(dir string, id rootFloorIdentity) (*moves.Floor, error) {
+ f, err := openRootProtocolFloors(dir, id, false)
if errors.Is(err, os.ErrNotExist) {
- public, e := rootstate.Open(rootFloorDir(opt), false, true)
+ public, e := rootstate.Open(dir, false, true)
if errors.Is(e, os.ErrNotExist) {
return nil, nil
}
@@ -85,14 +89,27 @@ func withEffectiveMovesFloorLock(source rootFloorIdentitySource, opt, dd string,
return err
}
defer unlock()
+ seeds, err := openRootProtocolFloors(rootSeedsFloorDir(opt), id, true)
+ if err != nil {
+ return err
+ }
+ defer seeds.close()
+ unlockSeeds, err := seeds.private.Lock(context.Background(), "floor.lock")
+ if err != nil {
+ return err
+ }
+ defer unlockSeeds()
return fn()
}
// Read both mirrors independently. Missing files add nothing; a malformed
// bounded file quarantines, while any usable sibling still contributes a deny.
func rootFloorLegacy(dd string) ([]*moves.Floor, error) {
+ return rootProtocolLegacy(dd, moves.FloorFile, moves.FloorMinReleaseFile)
+}
+func rootProtocolLegacy(dd, jsonFile, lineFile string) ([]*moves.Floor, error) {
var legacy []*moves.Floor
- fl, jerr := readRootLegacyFloorJSON(dd)
+ fl, jerr := readRootLegacyProtocolJSON(dd, jsonFile)
if fl != nil {
if validRootFloor(fl) {
legacy = append(legacy, fl)
@@ -100,7 +117,7 @@ func rootFloorLegacy(dd string) ([]*moves.Floor, error) {
jerr = errors.Join(jerr, errRootFloorHistory)
}
}
- file, err := openBeneath(dd, strings.Split(filepath.ToSlash(moves.FloorMinReleaseFile), "/")...)
+ file, err := openBeneath(dd, strings.Split(filepath.ToSlash(lineFile), "/")...)
if errors.Is(err, os.ErrNotExist) {
return legacy, jerr
}
@@ -139,7 +156,10 @@ func refreshRootMovesFloor(ctx context.Context, store release.Store, opt, dd str
// Sync after reading makes those already-fsynced file bytes durable before the
// honest agent advertises them, even if root died just after the rename.
func readRootFloorProjection(opt string) (rootFloorProjection, error) {
- s, err := rootstate.Open(rootFloorDir(opt), false, true)
+ return readRootProtocolProjection(rootFloorDir(opt))
+}
+func readRootProtocolProjection(dir string) (rootFloorProjection, error) {
+ s, err := rootstate.Open(dir, false, true)
if err != nil {
return rootFloorProjection{}, err
}
@@ -164,7 +184,10 @@ func readRootFloorProjection(opt string) (rootFloorProjection, error) {
}
func readRootLegacyFloorJSON(dd string) (*moves.Floor, error) {
- file, err := openBeneath(dd, strings.Split(filepath.ToSlash(moves.FloorFile), "/")...)
+ return readRootLegacyProtocolJSON(dd, moves.FloorFile)
+}
+func readRootLegacyProtocolJSON(dd, name string) (*moves.Floor, error) {
+ file, err := openBeneath(dd, strings.Split(filepath.ToSlash(name), "/")...)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
diff --git a/internal/host/rootseedsfloor.go b/internal/host/rootseedsfloor.go
new file mode 100644
index 000000000..91693c51d
--- /dev/null
+++ b/internal/host/rootseedsfloor.go
@@ -0,0 +1,142 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rootstate"
+ "github.com/carlosframework/platform/internal/seeds"
+)
+
+func rootSeedsFloorDir(opt string) string { return filepath.Join(opt, "system", "guard", "seeds") }
+func (b rootFloorBinary) forSeeds() rootFloorBinary { b.Protocol = b.SeedsProtocol; return b }
+func readRootSeedsBinary(opt, path string) (rootFloorBinary, error) {
+ b, err := readRootFloorBinary(opt, path)
+ return b.forSeeds(), err
+}
+func readEffectiveSeedsFloor(source rootFloorIdentitySource, opt, dd string) (*moves.Floor, error) {
+ id, required, err := source.read()
+ if err != nil {
+ return nil, err
+ }
+ if required {
+ return readRootProtocolBound(rootSeedsFloorDir(opt), id)
+ }
+ f, err := readLocalSeedsFloor(dd)
+ if f == nil {
+ return nil, err
+ }
+ return &moves.Floor{Min: f.Min, MinRelease: f.MinRelease}, err
+}
+func refreshRootSeedsFloor(ctx context.Context, store release.Store, opt, dd string, id rootFloorIdentity) error {
+ f, err := openRootProtocolFloors(rootSeedsFloorDir(opt), id, true)
+ if err != nil {
+ return err
+ }
+ defer f.close()
+ remote, rerr := seeds.ReadFloor(ctx, store)
+ var bound *moves.Floor
+ if remote != nil {
+ bound = &moves.Floor{Min: remote.Min, MinRelease: remote.MinRelease}
+ }
+ legacy, lerr := rootProtocolLegacy(dd, seeds.FloorFile, seeds.FloorMinReleaseFile)
+ return f.refresh(ctx, bound, legacy, errors.Join(rerr, lerr))
+}
+func judgeRootSeedsPrev(ctx context.Context, opt string, id rootFloorIdentity) error {
+ f, err := openRootProtocolFloors(rootSeedsFloorDir(opt), id, true)
+ if err != nil {
+ return err
+ }
+ defer f.close()
+ return (rootFloorJudge{floors: f, current: systemBinaryPath(opt), prev: systemPrevPath(opt), binary: func(path string) (rootFloorBinary, error) { return readRootSeedsBinary(opt, path) }, idle: rootFloorDowngradeIdle}).judge(ctx)
+}
+func rootRefreshAndJudgeSeedsPrev(id rootFloorIdentity) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ opt, dd := release.OptDir(), dataDir()
+ return withPrevLock(opt, func() error {
+ store, err := openDeployStore(ctx)
+ if err != nil {
+ return err
+ }
+ if err := refreshRootSeedsFloor(ctx, store, opt, dd, id); err != nil {
+ return err
+ }
+ return judgeRootSeedsPrev(ctx, opt, id)
+ })
+}
+func (a *systemApplier) seedsActivationRefusal(version string) error {
+ floor, err := readEffectiveSeedsFloor(a.rootMoves, a.optDir, a.dataDir)
+ if err != nil {
+ return refuse("seeds protocol floor: the floor on this box cannot be read: %v", err)
+ }
+ if floor == nil {
+ return nil
+ }
+ if ok, _ := seeds.ReleaseAtOrAbove(version, floor.MinRelease); !ok {
+ return refuse("seeds protocol floor: %s is below %s", version, floor.MinRelease)
+ }
+ return nil
+}
+func rootSeedsAllowsBinary(opt string, id rootFloorIdentity, path string) error {
+ floor, err := readRootProtocolBound(rootSeedsFloorDir(opt), id)
+ if err != nil {
+ return err
+ }
+ b, err := readRootSeedsBinary(opt, path)
+ if err != nil {
+ return err
+ }
+ if !b.supports(floor) {
+ return errRootFloorHistory
+ }
+ return nil
+}
+func withRootSeedsFields(f edgesFile, opt string) edgesFile {
+ f.SeedsProtocol = seeds.ProtocolVersion
+ f.SeedsFloorState = "quarantined"
+ f.SeedsFloorMinRelease, f.PrevSeedsAllowedFor = "", ""
+ p, err := readRootProtocolProjection(rootSeedsFloorDir(opt))
+ if err != nil {
+ return f
+ }
+ if p.Identity.Deployment != os.Getenv("CARLOS_DEPLOYMENT_BUCKET") || p.Identity.Box != f.Box {
+ return f
+ }
+ public, err := rootstate.Open(rootSeedsFloorDir(opt), false, true)
+ if err != nil {
+ return f
+ }
+ defer public.Close()
+ ad, err := rootFloorAdvertisementFor(public, p)
+ if err != nil || rootstate.Sync(public) != nil {
+ return f
+ }
+ f.SeedsFloorState, f.SeedsFloorGeneration = ad.State, ad.Generation
+ f.SeedsFloorMinRelease, f.PrevSeedsAllowedFor = ad.MinRelease, ad.PrevAllowedFor
+ return f
+}
+
+func (a *systemApplier) rootProtocolAllowsBinary(path string) error {
+ id, required, err := a.rootMoves.read()
+ if err != nil || !required {
+ return err
+ }
+ b, err := readRootFloorBinary(a.optDir, path)
+ if err != nil {
+ return err
+ }
+ floor, err := readRootProtocolBound(rootFloorDir(a.optDir), id)
+ if err != nil {
+ return err
+ }
+ if !b.supports(floor) {
+ return errRootFloorHistory
+ }
+ return rootSeedsAllowsBinary(a.optDir, id, path)
+}
diff --git a/internal/host/rootseedsfloor_root_linux_test.go b/internal/host/rootseedsfloor_root_linux_test.go
new file mode 100644
index 000000000..bad92878b
--- /dev/null
+++ b/internal/host/rootseedsfloor_root_linux_test.go
@@ -0,0 +1,84 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "context"
+ "crypto/ed25519"
+ "encoding/base64"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/seeds"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestPlan14RootSeedsFloorRootRunner(t *testing.T) {
+ for _, prev := range []bool{false, true} {
+ t.Run(map[bool]string{false: "absent", true: "present"}[prev], func(t *testing.T) {
+ opt := generationRootRunnerDir(t)
+ dd := filepath.Join(opt, "data")
+ if err := os.Mkdir(dd, 0755); err != nil {
+ t.Fatal(err)
+ }
+ id := rootFloorIdentity{"deploy", "box", "en"}
+ pub, key, _ := ed25519.GenerateKey(nil)
+ t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub))
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "deploy")
+ rootFloorSignRunnerBytes(t, opt, systemBinaryPath(opt), "v2.0.0", []byte("#!/bin/sh\nexit 0\n"), key)
+ if prev {
+ rootFloorSignRunnerBytes(t, opt, systemPrevPath(opt), "v2.0.0", []byte("#!/bin/sh\nexit 0 #prev\n"), key)
+ }
+ f, err := openRootProtocolFloors(rootSeedsFloorDir(opt), id, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.close()
+ a := moves.Floor{Min: 1, MinRelease: "v1.0.0"}
+ b := moves.Floor{Min: 1, MinRelease: "v2.0.0"}
+ if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &a, Source: "root history", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, f)}); err != nil {
+ t.Fatal(err)
+ }
+ j := rootFloorJudge{floors: f, current: systemBinaryPath(opt), prev: systemPrevPath(opt), binary: func(path string) (rootFloorBinary, error) { return readRootSeedsBinary(opt, path) }, idle: func(context.Context) error { return nil }}
+ if err := j.judge(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ store := release.NewDirStore(t.TempDir())
+ putSeedsFloor(t, store, seeds.Floor{Min: b.Min, MinRelease: b.MinRelease})
+ if _, err := syncLocalSeedsFloor(t.Context(), store, dd); err != nil {
+ t.Fatal(err)
+ }
+ edge := edgesFile{Box: id.Box, MovesFloorState: "accepted"}
+ if got := withSeedsFields(edge, dd, opt); got.SeedsFloorMinRelease != a.MinRelease {
+ t.Fatal("mirror advertised ahead", got)
+ }
+ if err := refreshRootSeedsFloor(t.Context(), store, opt, dd, id); err != nil {
+ t.Fatal(err)
+ }
+ if got := withSeedsFields(edge, dd, opt); got.SeedsFloorState == "accepted" {
+ t.Fatal("stale verdict accepted", got)
+ }
+ if err := j.judge(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ if got := withSeedsFields(edge, dd, opt); got.SeedsFloorState != "accepted" || got.SeedsFloorMinRelease != b.MinRelease {
+ t.Fatal("no root acceptance", got)
+ }
+ if err := os.RemoveAll(dd); err != nil {
+ t.Fatal(err)
+ }
+ aplier := systemApplier{optDir: opt, dataDir: dd, rootMoves: func() (rootFloorIdentity, bool, error) { return id, true, nil }}
+ if aplier.seedsActivationRefusal("v1.0.0") == nil {
+ t.Fatal("erased mirror permitted downgrade")
+ }
+ if err := aplier.seedsActivationRefusal("v2.0.0"); err != nil {
+ t.Fatal(err)
+ }
+ // The distinct moves namespace cannot inherit a seeds acceptance.
+ if _, err := readRootFloorProjection(opt); !os.IsNotExist(err) {
+ t.Fatal("moves namespace created", err)
+ }
+ })
+ }
+}
diff --git a/internal/host/rootseedsfloor_test.go b/internal/host/rootseedsfloor_test.go
new file mode 100644
index 000000000..8652c9b73
--- /dev/null
+++ b/internal/host/rootseedsfloor_test.go
@@ -0,0 +1,44 @@
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/moves"
+ "testing"
+)
+
+func TestPlan14RootSeedsFloorIndependent(t *testing.T) {
+ for _, family := range []string{"moves", "seeds"} {
+ t.Run(family, func(t *testing.T) {
+ f := rootMovesFloors{private: &rollbackMemory{files: map[string][]byte{}}, public: &rollbackMemory{files: map[string][]byte{}}, identity: rootFloorIdentity{"deploy", "box", "en"}}
+ a, b := moves.Floor{Min: 1, MinRelease: "v1.0.0"}, moves.Floor{Min: 2, MinRelease: "v2.0.0"}
+ if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: f.identity, Floor: &a, Source: "root history", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, f)}); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.refresh(t.Context(), &a, []*moves.Floor{&b}, nil); err == nil {
+ t.Fatal("legacy ahead accepted")
+ }
+ if _, err := f.projection(); err == nil {
+ t.Fatal("deny-only floor advertised")
+ }
+ bound, err := f.localBound()
+ if err != nil || bound == nil || *bound != b {
+ t.Fatal("lost legacy deny", bound, err)
+ }
+ if err := f.refresh(t.Context(), nil, nil, nil); err == nil {
+ t.Fatal("remote deletion repaired refusal")
+ }
+ if err := f.refresh(t.Context(), &b, nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if p, err := f.projection(); err != nil || *p.Floor != b {
+ t.Fatal(p, err)
+ }
+ })
+ }
+ if rootSeedsFloorDir("/opt/carlos") == rootFloorDir("/opt/carlos") {
+ t.Fatal("floors share authority")
+ }
+ b := rootFloorBinary{Version: "v2.0.0", Digest: "signed", Protocol: 5, SeedsProtocol: 1}
+ if b.forSeeds().supports(&moves.Floor{Min: 2, MinRelease: "v1.0.0"}) {
+ t.Fatal("moves protocol authorized seeds")
+ }
+}
diff --git a/internal/host/seedsfloor.go b/internal/host/seedsfloor.go
index 4ef17e39a..97b5aa96a 100644
--- a/internal/host/seedsfloor.go
+++ b/internal/host/seedsfloor.go
@@ -55,6 +55,9 @@ func readLocalSeedsFloor(dataDir string) (*seeds.Floor, error) {
// withSeedsFields sets the edges file's seeds fields from this box's own
// files, read at each write, as withMovesFields does for the moves floor.
func withSeedsFields(f edgesFile, dataDir, optDir string) edgesFile {
+ if _, err := os.Lstat(rootSeedsFloorDir(optDir)); f.MovesFloorState != "" || !errors.Is(err, os.ErrNotExist) {
+ return withRootSeedsFields(f, optDir)
+ }
f.SeedsProtocol = seeds.ProtocolVersion
f.SeedsFloorMinRelease = ""
if fl, err := readLocalSeedsFloor(dataDir); err == nil && fl != nil {
@@ -204,6 +207,11 @@ func seedsActivationRefusal(version, dataDir string) error {
// floor this box could not write down defers the apply; anything else is a
// log line.
func (a *systemApplier) armSeedsFloor(ctx context.Context) error {
+ if id, required, err := a.rootMoves.read(); err != nil {
+ return err
+ } else if required {
+ return refreshRootSeedsFloor(ctx, a.store, a.optDir, a.dataDir, id)
+ }
f, err := syncLocalSeedsFloor(ctx, a.store, a.dataDir)
if err == nil {
return nil
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go
index 174e588c0..1b63597ed 100644
--- a/internal/host/systemapply.go
+++ b/internal/host/systemapply.go
@@ -522,6 +522,9 @@ func (a *systemApplier) install(ctx context.Context, req systemRequest, p *relea
// and the holds still across the rename.
renamed := false
if err := a.activationGuard(target, running, func() error {
+ if err := a.rootProtocolAllowsBinary(staged); err != nil {
+ return err
+ }
renamed = true
return os.Rename(staged, systemBinaryPath(a.optDir))
}); err != nil {
@@ -805,7 +808,7 @@ func (a *systemApplier) notePrev(ctx context.Context, replace bool, version stri
}
defer f.close()
j := rootFloorJudge{floors: f, current: systemBinaryPath(a.optDir), prev: systemPrevPath(a.optDir), idle: rootFloorDowngradeIdle, binary: func(path string) (rootFloorBinary, error) { return readRootFloorBinary(a.optDir, path) }}
- return errors.Join(j.judge(ctx), judgeSeedsPrev(a.optDir, a.dataDir))
+ return errors.Join(j.judge(ctx), judgeRootSeedsPrev(ctx, a.optDir, id))
}
return a.floorStep(errors.Join(judgePrevWith(a.optDir, a.dataDir, a.freezeHolds, func() string { return a.prevBuild(ctx) }), judgeSeedsPrev(a.optDir, a.dataDir)))
}
@@ -830,7 +833,8 @@ func (a *systemApplier) notePrev(ctx context.Context, replace bool, version stri
// since the floor's steps write and judge both.
func (a *systemApplier) floorArmed() bool {
f, err := readEffectiveMovesFloor(a.rootMoves, a.optDir, a.dataDir)
- return err != nil || f != nil || seedsFloorArmed(a.dataDir)
+ sf, se := readEffectiveSeedsFloor(a.rootMoves, a.optDir, a.dataDir)
+ return err != nil || f != nil || se != nil || sf != nil
}
// floorStep is the dormancy rule for the floor's own steps (moves Task 13,
@@ -980,7 +984,7 @@ func (a *systemApplier) activationGuard(target, running string, fn func() error)
locked := false
err := withEffectiveMovesFloorLock(a.rootMoves, a.optDir, a.dataDir, true, func() error {
locked = true
- if err := seedsActivationRefusal(target, a.dataDir); err != nil {
+ if err := a.seedsActivationRefusal(target); err != nil {
return err
}
floor, err := readEffectiveMovesFloor(a.rootMoves, a.optDir, a.dataDir)
@@ -1061,7 +1065,7 @@ func holdRefusal(version, running string, held []registry.Route) error {
// guards nothing by itself; the step it precedes runs under
// activationGuard.
func (a *systemApplier) activationRule(version string, running func() string) error {
- if err := seedsActivationRefusal(version, a.dataDir); err != nil {
+ if err := a.seedsActivationRefusal(version); err != nil {
return err
}
floor, err := readEffectiveMovesFloor(a.rootMoves, a.optDir, a.dataDir)
@@ -1095,7 +1099,7 @@ func (a *systemApplier) activationRule(version string, running func() string) er
// the gated build is already the one on disk, so only the floor can refuse
// it. Unarmed: allowed.
func (a *systemApplier) floorAllowsForward(version string) error {
- if err := seedsActivationRefusal(version, a.dataDir); err != nil {
+ if err := a.seedsActivationRefusal(version); err != nil {
return err
}
floor, err := readEffectiveMovesFloor(a.rootMoves, a.optDir, a.dataDir)
@@ -1693,6 +1697,9 @@ func (a *systemApplier) restoreFiles(ctx context.Context, j *systemJournal) (boo
}
renamed := false
if err := a.activationGuard(prevVersion, running, func() error {
+ if err := a.rootProtocolAllowsBinary(restore); err != nil {
+ return err
+ }
renamed = true
if err := os.Rename(restore, systemBinaryPath(a.optDir)); err != nil {
return err
@@ -1845,6 +1852,9 @@ func (a *systemApplier) recoverIfNeeded(ctx context.Context) (bool, error) {
// floor can rise while the plan is staged (fix round 2).
finished := false
err = a.activationGuard(j.Target, j.Target, func() error {
+ if err := a.rootProtocolAllowsBinary(systemBinaryPath(a.optDir)); err != nil {
+ return err
+ }
finished = true
return a.finish(req, j.Target, plan)
})
diff --git a/internal/hostguard/guard.go b/internal/hostguard/guard.go
index 1893386e0..6e82db90f 100644
--- a/internal/hostguard/guard.go
+++ b/internal/hostguard/guard.go
@@ -219,7 +219,7 @@ func (r record) admits(c *hostclaim.Claim, captured Captured) error {
if captured.Stamp != r.ActiveStamp {
return ErrStampMismatch
}
- if migrationDenied(r.migrationDenies, captured, c.Holder.Session) {
+ if migrationAdmissionDenied(r.migrationDenies, r.FenceJournal, captured, c.Holder.Session) {
return ErrFenced
}
if r.ActiveFence != nil && r.ActiveFence.matchesSession(c, captured) {
diff --git a/internal/hostguard/plan14_root_fences_test.go b/internal/hostguard/plan14_root_fences_test.go
index 3b4736de3..fc55fdf5f 100644
--- a/internal/hostguard/plan14_root_fences_test.go
+++ b/internal/hostguard/plan14_root_fences_test.go
@@ -326,7 +326,9 @@ func TestPlan14RootInventoryCommitRetainsDenials(t *testing.T) {
}
}
-func TestPlan14ColdHandoverRefreshesRootInventory(t *testing.T) {
+func TestPlan14ColdHandoverRefreshesRootInventory(t *testing.T) { testRootColdHandover(t, false) }
+func TestPlan14MigrationPredecessorReadmission(t *testing.T) { testRootColdHandover(t, true) }
+func testRootColdHandover(t *testing.T, migration bool) {
f, prior, captured := fenceFixture(t)
f.obs.Claim.Holder.Account, f.obs.Claim.Holder.App = "bab", "app"
store := &rootTestStore{dir: t.TempDir()}
@@ -340,6 +342,11 @@ func TestPlan14ColdHandoverRefreshesRootInventory(t *testing.T) {
if err := f.g.EstablishRoot(t.Context(), in, allow); err != nil {
t.Fatal(err)
}
+ if migration {
+ if err := f.g.Root.RetainMigrationDeny(t.Context(), in, prior.Session); err != nil {
+ t.Fatal(err)
+ }
+ }
if _, err := f.g.BeginFence(t.Context(), prior); err != nil {
t.Fatal(err)
}
@@ -358,9 +365,22 @@ func TestPlan14ColdHandoverRefreshesRootInventory(t *testing.T) {
if err := f.g.Activate(t.Context(), prior.Host, prior.Box, nil); err == nil {
t.Fatal("activation cleared cold barrier")
}
- if err := f.g.CompleteColdHandover(t.Context(), next, false, allow, func() error { return nil }); err != nil {
+ if migration {
+ fail := errors.New("interrupted handover")
+ if err := f.g.CompleteColdHandover(t.Context(), next, false, allow, func() error { return fail }); !errors.Is(err, fail) {
+ t.Fatal(err)
+ }
+ if err := f.g.With(t.Context(), prior.Host, Captured{Incarnation: prior.Incarnation, Stamp: 17}, allow, func() error { t.Fatal("pending successor ran"); return nil }); err == nil {
+ t.Fatal("pending handover allowed")
+ }
+ }
+ completed := 0
+ if err := f.g.CompleteColdHandover(t.Context(), next, false, allow, func() error { completed++; return nil }); err != nil {
t.Fatal(err)
}
+ if err := f.g.CompleteColdHandover(t.Context(), next, true, allow, func() error { completed++; return nil }); err != nil || completed != 1 {
+ t.Fatal("handover replay", err, completed)
+ }
view, err := f.g.Root.View(t.Context(), prior.Host, Captured{Incarnation: prior.Incarnation, Stamp: 17})
if err != nil || view.Installation.InventoryRevision != 2 || view.Barrier != nil {
t.Fatalf("successor snapshot: %+v %v", view, err)
diff --git a/internal/hostguard/root.go b/internal/hostguard/root.go
index b6f2f5a8b..57d634dfd 100644
--- a/internal/hostguard/root.go
+++ b/internal/hostguard/root.go
@@ -165,7 +165,7 @@ func (g Guard) CommitRootInventoryArtifacts(ctx context.Context, in RootInstalla
return ErrRecordIncarnation
}
}
- if migrationDenied(r.migrationDenies, want, c.Holder.Session) {
+ if migrationAdmissionDenied(r.migrationDenies, r.FenceJournal, want, c.Holder.Session) {
return ErrFenced
}
if r.ActiveFence != nil && r.ActiveFence.matchesSession(c, want) {
diff --git a/internal/hostguard/rootmigration.go b/internal/hostguard/rootmigration.go
index df74858db..8ffdaa692 100644
--- a/internal/hostguard/rootmigration.go
+++ b/internal/hostguard/rootmigration.go
@@ -83,3 +83,24 @@ func (a *RootAuthority) CompleteMigrationInventory(ctx context.Context, target F
r.installation.Artifacts = artifacts
return a.write(r)
}
+
+func migrationAdmissionDenied(denies []MigrationDeny, receipts []FenceReceipt, c Captured, session uint64) bool {
+ if migrationDenied(denies, c, session) {
+ return true
+ }
+ for _, d := range denies {
+ if d.Incarnation != c.Incarnation || d.Stamp != c.Stamp {
+ continue
+ }
+ stopped := false
+ for _, r := range receipts {
+ if r.Target.Incarnation == d.Incarnation && r.Target.Stamp == d.Stamp && r.Target.Session == d.Session {
+ stopped = true
+ }
+ }
+ if !stopped {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/hostguard/rootmigration_test.go b/internal/hostguard/rootmigration_test.go
index 5c7ae54af..c63b13353 100644
--- a/internal/hostguard/rootmigration_test.go
+++ b/internal/hostguard/rootmigration_test.go
@@ -3,8 +3,10 @@
package hostguard
import (
+ "context"
"errors"
"testing"
+ "time"
)
func TestPlan14MigrationDenyCoversRootAndAgent(t *testing.T) {
@@ -35,6 +37,17 @@ func TestPlan14MigrationDenyCoversRootAndAgent(t *testing.T) {
if err := view.admit(target.Host, captured, target.Session); !errors.Is(err, ErrFenced) {
t.Fatal("agent view bypass", err)
}
+ target, err = f.g.BeginFence(t.Context(), target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = f.g.CompleteFence(t.Context(), target, []byte("verified predecessor"), time.Now(), func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ view, err = root.View(t.Context(), target.Host, captured)
+ if err != nil {
+ t.Fatal(err)
+ }
f.obs.Claim.Holder.Session++
if err := f.g.With(t.Context(), target.Host, captured, allow, func() error { return nil }); err != nil {
t.Fatal("fresh authoritative session denied", err)
@@ -50,3 +63,35 @@ func TestPlan14MigrationDenyCoversRootAndAgent(t *testing.T) {
t.Fatal("deny regressed", again, err)
}
}
+
+func TestPlan14MigrationReadmissionRequiresCompletedPredecessor(t *testing.T) {
+ f, target, captured := fenceFixture(t)
+ root := NewRootAuthority(&rootTestStore{dir: t.TempDir()})
+ f.g.Root = root
+ f.obs.Claim.Holder.Account, f.obs.Claim.Holder.App = "bab", "app"
+ in := RootInstallation{Host: target.Host, Incarnation: captured.Incarnation, Stamp: captured.Stamp, Account: "bab", App: "app", Record: "record", Box: target.Box, Enrollment: target.Enrollment, Migration: 1}
+ if err := root.RetainMigrationDeny(t.Context(), in, target.Session); err != nil {
+ t.Fatal(err)
+ }
+ f.obs.Claim.Holder.Session++
+ if f.g.With(t.Context(), target.Host, captured, allow, func() error { return nil }) == nil {
+ t.Fatal("session advance bypassed predecessor stop")
+ }
+ f.obs.Claim.Holder.Session--
+ target, err := f.g.BeginFence(t.Context(), target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := f.g.CompleteFence(t.Context(), target, []byte("verified exact root stop"), time.Now(), func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ f.obs.Claim.Holder.Session++
+ effects := 0
+ if err := f.g.With(t.Context(), target.Host, captured, allow, func() error { effects++; return nil }); err != nil || effects != 1 {
+ t.Fatal("completed predecessor", err, effects)
+ }
+ f.obs.Claim.Holder.Session--
+ if f.g.With(t.Context(), target.Host, captured, allow, func() error { t.Fatal("old session ran"); return nil }) == nil {
+ t.Fatal("deny lost")
+ }
+}
diff --git a/internal/hostguard/rootview.go b/internal/hostguard/rootview.go
index a96ac3e59..862daed6b 100644
--- a/internal/hostguard/rootview.go
+++ b/internal/hostguard/rootview.go
@@ -89,7 +89,7 @@ func (v FenceView) admit(host string, c Captured, claimSession uint64) error {
return ErrRetiredStamp
}
}
- if migrationDenied(v.MigrationDenies, c, claimSession) {
+ if migrationAdmissionDenied(v.MigrationDenies, v.Receipts, c, claimSession) {
return ErrFenced
}
if v.Barrier != nil {
diff --git a/internal/provision/testdata/plan14-root-reads-p3.tsv b/internal/provision/testdata/plan14-root-reads-p3.tsv
index c9817e5b9..c6b5f86ff 100644
--- a/internal/provision/testdata/plan14-root-reads-p3.tsv
+++ b/internal/provision/testdata/plan14-root-reads-p3.tsv
@@ -1,6 +1,8 @@
-# Plan 14 Part 3 run 2; all rows remain proposed for controller review.
+# Plan 14 Part 3 run 3; all rows remain proposed for controller review.
# Earlier overlays and frozen legacy fingerprints are unchanged.
internal/console/boxselect.go Server.selectMoveBoxes authority F,J4 rev3=PROPOSED-p3;identity=carlos console serve, console.service uid carlos-console;basis=applies accepted/generation refusal only to advertised root-floor participants; legacy selection retains main exactly d51a6a29a190dd36e896567adf4e0e0e1c8c9a08ff909cfbedd7732c2321ae78
+internal/console/seedfloor.go Server.seedRegionReady authority F,J4 rev3=PROPOSED-p3;identity=carlos console serve uid carlos-console;basis=requires accepted seeds generation only from root-floor participants; mixed fleet nonparticipants retain exact legacy readiness d116ff2f72a1386a3a4120c69fea671cd3d43af264a20a1b8e98b5c61f11efb2
+internal/host/claimconstruction.go OpenProductionClaimAssembly authority G,J4 rev3=PROPOSED-p3;identity=root claim controller construction uid 0;basis=verifies complete retained migration and typed inventories before constructing claim authority on a census box; no census preserves prior path file:Read@4278,file:Read@9540,file:ReadFile@3822,registry:Host@9567,whole:Route@9655 9c00a440613c5fdc402ef9fd038d6e193ac88dace1a0430d3fe59557efbce90c
internal/host/claimjournal_migration.go RunSystemJournalMigrate authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=explicit offline root operator entry requests full G2 bootstrap after verified claim owner setup 4f204d8a97620bfb22430a94e236d6c5b14a5265b35f68e21fb2ad34903ae0f3
internal/host/claimjournal_migration.go migrateClaimJournals authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=retains legacy migration helper for existing import/mask behavior; explicit operator entry requests G2 separately e21da6b220796cc799dcd6dcbfe5b566b7bb6753394c786f44ac66cfcc07c6e3
internal/host/claimjournal_migration.go migrateClaimJournalsWith authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=opens verified actor and root journal then selects full offline census or unchanged legacy journal import e9b719120521b324163865a620812115a126cafc21b62ffa41282cf4e8093c34
@@ -10,6 +12,7 @@ internal/host/guardtransaction.go systemApplier.guardRun authority F,J4 rev3=PRO
internal/host/movesfloor.go rootJudgeMovesPrev authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt root pass, root unit uid 0;basis=adopt-only wrapper delegates to root-aware moves judge; no direct input reads 0c9aa7c7df1ddd5b21798bc0158c5496dad16d400a69e8ace2dd61e8e16f0d58
internal/host/movesfloor.go rootJudgeMovesPrevWithSource authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt root pass, root unit uid 0;basis=adopt refreshes root floor only for census participants; legacy registry judgement otherwise unchanged file:Stat@289,file:Stat@490 fa942c1c4b8af2d2930be427e303f3b9838c0fce33f1b3f8e63e17093b638269
internal/host/movesfloor.go rootJudgePrev authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt root pass, root unit uid 0;basis=adopt combines moves and seeds verdict errors; seeds root ratchet still pending P3-R4 8b5ecf5a0f33ee58d603f308302bcc8e68678da6b1e6ee7ed03845c35747f8c6
+internal/host/movesfloor.go rootJudgePrevWithSource authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=normal adopt path refreshes and judges both independent protocol floors only on census boxes 8d25e75b26a43b93f4c635b5d3a9d25bf41f60a02face307dc11034c706dd916
internal/host/movesfloor.go withMovesFields authority F,J4 rev3=PROPOSED-p3;identity=carlos agent edgesFileLoop, carlos-edge.service uid carlos;basis=agent uses root-owned public projection when participating and its original writable mirror otherwise file:Lstat@238,file:Stat@697 3e615afa769bb64bcb0ffdc1ad7b85b67883a34cd6afd83ce7da74a1f4218983
internal/host/movesfloorgate.go floorGateMain authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=dispatches explicit root previous gate; legacy argument shape preserved 6123e52c08365414c1135aef0fbcdbadcd68b38596cc81d8d9ace482eb555949
internal/host/rootmigration.go rootMigration.census authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=writes pending or complete root participation identity and revokes failed completion writes 7d9e3a6c0ed825e22e0a9c2d1910656f612acb37a1b217c2170c1f682f309d37
@@ -18,13 +21,21 @@ internal/host/rootmigration.go rootMigration.write authority G,J4 rev3=PROPOSED-
internal/host/rootmigration.go rootMigrationSnapshot.fingerprint authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=binds exclusion and membership across observations/restart while allowing boot metadata and independently recovered payload revision 115b07f84d5aa210cd35d70e2ba4b53ff4deb0e4636a737a5dc144bf34a9473c
internal/host/rootmigration.go rootMigrationSnapshot.preflight authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=refuses active moves, unfinished seeds, incomplete inventories or mismatched migration identity before effects 60675bcf7e54360a9daaddffb9458b5eab2636c3db8b9a2c87541c6e4dbbce97
internal/host/rootmigration_census.go rootMigrationCollector.collect authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=signed collecting manifest and actor directory bound complete records/claims census; registry Seeds are refusal-only file:Exec@4684,file:Read@2868,file:Read@689 6cfe43917f24a6dd6c7e3eee503f84897c944ec0c251ddfc64c16884f5090438
-internal/host/rootmigration_production_linux.go migrateRootClaimAuthority authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=offline root composes claim denies, authenticated legacy proofs, root inventory and kernel scan under release/mask exclusion file:Open@1538,file:Open@302,file:Open@384,file:Open@508,file:Read@2593,file:ReadFile@1340,whole:Route@2927 ee738c2f20fdc767f96fe1a90e0e28ec67ec52f93b4512f97f86ac7b56875ecb
+internal/host/rootmigration_evidence.go readMigrationStop authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate offline operator uid 0;basis=revalidates enrolled immutable stop signature and exact launch inventory journal tuple; preserves original bytes and evidence time file:Read@592 ee9a331a4c022d045acff9eca5003f4df79f9f872ca132b1fa37b150d16338d9
+internal/host/rootmigration_production_linux.go migrateRootClaimAuthority authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate offline operator uid 0;basis=signed collecting manifest excludes admission; enrolled immutable stop plus root journal and kernel absence establish inventory; registry timers can only refuse unexplained artifacts; publishes complete Seeds census after retained evidence file:Open@1538,file:Open@302,file:Open@384,file:Open@508,file:Read@2593,file:ReadFile@1340,whole:Route@3070 9807e5916f0eac64511c2eda0202615b14bb0584f663ccec4ed5b69e19349970
internal/host/rootmigration_production_linux.go migrationExecutionEmpty authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=any retained execution entry other than census/locks refuses migration; conservative settled-record limitation remains c17efaf8e7191cf0cd36d0ee462793c78094e40aef46567a446e503aee494641
internal/host/rootmigration_production_linux.go publishMigrationReceipt authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=reconstructs immutable key/digest from original signed receipt bytes without changing evidence time db91dbe64b57fe10aa1635fb46938e2ccb585afb6aa138800507acac6a83c377
internal/host/rootmigration_production_linux.go randomMigrationID authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=cryptographic nonce identifies newly collected stop evidence, never authorizes a stop file:Read@64 c2779c80b5628eca7d484ffba0b289c1068317b1f474c06e1ee5b39c3f769196
internal/host/rootmigration_production_linux.go rootMigrationLegacySuspects authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=agent-writable hostguard filenames only identify unexplained hosts to refuse, never authorize inventory file:Readdirnames@277 6bfa73d903baa8a028abb007a7ec1da0066dbcda966e92e29b50a2b56fda10ab
internal/host/rootmigration_production_other.go migrateRootClaimAuthority authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=offline root composes claim denies, authenticated legacy proofs, root inventory and kernel scan under release/mask exclusion 327e0adfe387eac7bc74b87258efc9831f5a35ee9c9c9e71640d4e6b54853aae
-internal/host/rootmovesfloor.go openRootMovesFloors authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=opens checked public and private root-owned floor descriptors; private parent is mode 0700 file:Open@167,file:Open@269 4ceed7e158d3cfc0b9b77df47c7656aea044f95e0706ed3cb9e1df698995f42a
+internal/host/rootmigration_startup.go var openRootMigrationParticipation authority G,J4 rev3=PROPOSED-p3;identity=root claim controller construction uid 0;basis=fixed production participation opener remains rootparticipation.OpenForUpdate; package tests substitute explicit no-census fixtures without accessing the machine store 53a17d908b433d98cdb817ab72543bd53788962625ccc9cfa0e48f829bbc1aa3
+internal/host/rootmigration_startup.go verifyRootMigrationFloors authority G,F,J4 rev3=PROPOSED-p3;identity=root claim controller construction uid 0;basis=checks installed and guard signed binaries against both locally retained protocol bounds without remote access 257eac6eb0de322ca5ad251f98579f81c4fcad58aa51f586edae11b9e14004c9
+internal/host/rootmigration_startup.go verifyRootMigrationRetained authority G,F,J4 rev3=PROPOSED-p3;identity=root claim controller construction uid 0;basis=requires each migrated installation to retain its old-session deny, exact stopped predecessor receipt and complete root inventory f2dc230494f4659e6d4bf71961d617854454bd8f713585a1cd4489414c288cb1
+internal/host/rootmigration_startup.go verifyRootMigrationStartup authority G,F,J4 rev3=PROPOSED-p3;identity=root claim controller construction uid 0;basis=checks root-owned migration records and typed journal revisions before issuing startup grants; absent census leaves main path intact file:Open@337 b5149f8db369dbf474612ec6f6afe55c518f337f619479bdfeb648f518d4c5af
+internal/host/rootmigration_startup.go verifyRootMigrationState authority G,F,J4 rev3=PROPOSED-p3;identity=root claim controller construction uid 0;basis=binds complete root census and durable complete migration record to deployment box enrollment and migration generation file:Read@415 7d09ad99c73d534730d092d873ba558eb2a0d352b5f8f9a0d50b9530eba1dff2
+internal/host/rootmigration_stopped_linux.go verifyMigrationStopped authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate offline operator uid 0;basis=root checks real proc/cgroup mounts, closed writer journal and absence of cgroups and held inventory descriptors; all observations can only refuse recovery file:ReadFile@588 06b82196857608ace8bfaa8ce7108caad961868b530a0d9cb152326a1aaf6819
+internal/host/rootmovesfloor.go openRootMovesFloors authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=selects fixed moves namespace then delegates checked root store opening 4af0bda66779af3474e64fda54015763761d058feead1b8a2ebf0c2478a05a3d
+internal/host/rootmovesfloor.go openRootProtocolFloors authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=checked root ancestry and leaf ownership for separate moves or seeds public/private stores file:Open@170,file:Open@258 a3b589e05e8cdc1062162562006ef7f93cc3159f2f24a10a155c038ce7e3ca28
internal/host/rootmovesfloor.go rootMovesFloors.localBound authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=retains every readable independent deny; missing or corrupt history adds no invented refusal or acceptance file:Read@583 cc70feddffa2f611e9de3250d42b84a506f3d5ee688921c551125c4df60ac43a
internal/host/rootmovesfloor.go rootMovesFloors.persist authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=writes quarantine before selector and deny ratchet, then accepted state, then synced public acknowledgement 2c1e0843c5865a31c0fab011d7d4825963bbb5a301727f37f3b1b6c3519eba5e
internal/host/rootmovesfloor.go rootMovesFloors.projection authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=matches public acceptance to private generation, identity and retained componentwise maximum file:Read@109 6b0ed0053db247385ccbabfd95a738fd48a8d27b14882b5e73b4e5a82eda26b6
@@ -34,13 +45,13 @@ internal/host/rootmovesfloor.go rootMovesFloors.refresh authority F,J4 rev3=PROP
internal/host/rootmovesfloor.go rootMovesFloors.selector authority F,J4 rev3=PROPOSED-p3;identity=root apply/recover/adopt units uid 0 and agent edges publisher carlos-edge.service uid carlos;basis=strict bounded canonical protocol/release line supplies independent crash-surviving deny file:Read@73 5c46438590323610c67dc5bbfaa191fc5ba23e3b280b6063dbd48e48e7ce3927
internal/host/rootmovesfloor_authorization.go rootMovesFloors.authorizeReconstruction authority F,J4 rev3=PROPOSED-p3;identity=offline root reconstruction capability, uid 0; no production caller installed;basis=issues private nonce capability for this root identity and next generation under stable lock; no production caller file:Read@426 a5235f6aab5714cee3e3e41604eed5b1eff7aa4355f9444f44c4811f7dfbbc08
internal/host/rootmovesfloor_authorization.go rootMovesFloors.authorizedReconstruction authority F,J4 rev3=PROPOSED-p3;identity=offline root reconstruction capability, uid 0; no production caller installed;basis=requires exact private persisted nonce, box identity and next generation; caller strings cannot authorize file:Read@249 c449d7087a6cbcdf4388b7945e84045a0b895349b1bc14347aca7040c822391c
-internal/host/rootmovesfloor_boot.go rootFloorAllowsPrev authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=old-reader-resistant root gate requires current generation verdict and signed previous executable 8fb85b6abbc251b4456d6ad6cce02331048804870e6b64a56e12f8fda0107d0d
+internal/host/rootmovesfloor_boot.go rootFloorAllowsPrev authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=signed prev identity must satisfy both local protocol floors and root downgrade completeness; sidecars never grant ffe7eca89c202165e6ed990667016aec67ced60519655dfd0db1921b61db8411
internal/host/rootmovesfloor_boot.go rootFloorBootCandidate authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=local-only selection requires previous verdict plus idle proof; fallback current must be signed and support all readable denies 386a486b3235ad5152e009281500dc0f54e00283d96ea44f9d5a28eacd3f6758
internal/host/rootmovesfloor_boot.go rootFloorJournalPresent authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=checked authority-file presence selects recovery without opening a deployment store b189d04dc579f4825f5668efd1f93f178acca738bb2ed886a14f3b5321d7755b
internal/host/rootmovesfloor_boot.go rootFloorPrevVerdict authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=matches actual signed previous binary to identity and accepted generation before allowing its gate file:Read@131 04a9909b6ff4c81380c1d974ce028ae525ff53513fb71f78b0e1076d35391967
-internal/host/rootmovesfloor_boot.go withRootFloorGuardCandidate authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=holds release, previous-binary and private-floor exclusion around local signed selection and exec file:Open@218 2426c860c29e05c74bfb9d1beaab396688bad4fab4f1f1d278026099bea44337
+internal/host/rootmovesfloor_boot.go withRootFloorGuardCandidate authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=holds moves then seeds locks across selector callback; checks signed candidate against both bounds and allows independently checked current fallback file:Open@218 3d268112ee87024d73e1930a3483d92e12016b69c66fc36b04ed06adfee87000
internal/host/rootmovesfloor_identity.go rememberRootFloorSystemIdentity authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=re-fetches and verifies signed pointer equality before retaining its executable identity file:Open@543 b4e56a0637c28e5fb65d3a33b7dfd1fea8bed55acbf5231bf26e0011168141fe
-internal/host/rootmovesfloor_refresh.go readRootFloorBinary authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=checked root executable inode/digest must match independently verified signed identity envelope file:Open@684,file:Read@941,file:Stat@276 47062453004252de69e807a67ac94e40243d69b3a55033dacf2d8354a5d635a6
+internal/host/rootmovesfloor_refresh.go readRootFloorBinary authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=verifies checked executable and signed system manifest; reads signed moves and seeds protocol numbers rather than version sidecar file:Open@684,file:Read@941,file:Stat@276 2a23dd9c46c6fa56476ecfa4061712f05c6f1a1a098295e14f395def2d24324a
internal/host/rootmovesfloor_refresh.go rootFloorAdvertisementFor authority F,J4 rev3=PROPOSED-p3;identity=carlos agent edgesFileLoop, carlos-edge.service uid carlos;basis=agent reports accepted only for matching generation with signed compatible current and previous verdict file:Read@203 4e464b6add24a06b1c3d7a5859be7a1055750bc25f27c8ca641241be3baa2ef8
internal/host/rootmovesfloor_refresh.go rootFloorBinary.supports authority F,J4 rev3=PROPOSED-p3;identity=root apply/recover/adopt units uid 0 and agent edges publisher carlos-edge.service uid carlos;basis=signed digest/release are required; protocol minimum applies only when a retained floor exists, preserving unknown-history permission 7a6eaf5432f2668aa47f24d9c667355a985f83eadb8c4619a704168288b16fbf
internal/host/rootmovesfloor_refresh.go rootFloorDowngradeIdle authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=root participation ledger and staged-generation pins may only refuse downgrade file:Open@68 8bb82e0b179e0dfadd6f375299d9f40ceea1c2b911b1d53f18fb4958957c74ac
@@ -48,38 +59,57 @@ internal/host/rootmovesfloor_refresh.go rootFloorJudge.judge authority F,J4 rev3
internal/host/rootmovesfloor_refresh.go rootFloorJudge.refresh authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=refreshes retained floor before signed current/previous judgement, even when previous is absent 362798a60b4e3d6fc798db52bfc204979f724efe33a858f3d7ca2c1ca12f6eaa
internal/host/rootmovesfloor_refresh.go rootRefreshAndJudgeMovesPrev authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt root pass, root unit uid 0;basis=adopt opens deployment store then refreshes floor before any no-prev return 2d4d1ce1d6143555f3b94b19359c014ffb20272f41f756f7c02bbf6ff29a70a9
internal/host/rootmovesfloor_refresh.go withRootMovesFields authority F,J4 rev3=PROPOSED-p3;identity=carlos agent edgesFileLoop, carlos-edge.service uid carlos;basis=agent publishes only checked root public verdict and explicitly quarantines unreadable participant state file:Open@405 29fb652e22df888f18c691b91db6b83b9781d89d005a2fae431d8102e77b2f88
-internal/host/rootmovesfloor_source.go readEffectiveMovesFloor authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=root enforcement uses all readable retained denies; no census uses original local mirror file:Open@321 287a0c1120786ed693b2b138c40082fc9eb5f5104065ec317b32ac1e7473f7ae
-internal/host/rootmovesfloor_source.go readRootFloorProjection authority F,J4 rev3=PROPOSED-p3;identity=root apply/recover/adopt units uid 0 and agent edges publisher carlos-edge.service uid carlos;basis=checked root public read plus directory fsync precedes honest acknowledgement file:Open@83,file:Read@243 170e020e2f05437de39145566fdd4055cc11bd668fe24a1ca2458ece49195aeb
-internal/host/rootmovesfloor_source.go readRootLegacyFloorJSON authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=agent-writable legacy JSON is bounded strict deny-only evidence; never permission file:ReadAll@443,file:Stat@280 1af52c869107011648314ac12a9687ce641329b5a92fa7d487c278fa082bf4f2
+internal/host/rootmovesfloor_source.go readEffectiveMovesFloor authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=census chooses local root protocol bound; no census chooses existing legacy reader a6420f9074a459fa5107cbd60ca18828f0dec132eb53add849152db2a9b0f62a
+internal/host/rootmovesfloor_source.go readRootFloorProjection authority F,J4 rev3=PROPOSED-p3;identity=carlos agent in carlos-edge.service uid carlos;basis=selects moves public root projection for checked advertisement reader 572e2d0d6a783e823c3c76ca08c38fd86abc5265486c246bb1b123d834fea485
+internal/host/rootmovesfloor_source.go readRootLegacyFloorJSON authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=selects existing moves JSON for bounded denial-only protocol reader 41475b13c77395562264e21faad32b142b053fc199919c39e1eeba28f0f17155
+internal/host/rootmovesfloor_source.go readRootLegacyProtocolJSON authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=bounded descriptor read of optional legacy moves or seeds mirror; shape validation and caller denial-only merge prevent acceptance file:ReadAll@441,file:Stat@278 835f913509edbe65d72986bb23e7af580aaf2d4cc222e87064e29d0d16e61103
+internal/host/rootmovesfloor_source.go readRootProtocolBound authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=reads local root accepted and retained deny state; public-only deny survives missing private history; missing unknown history is not acceptance file:Open@187 2e35ae8bdee1433877d928f22b4a3315dfd2027378a71124ca361284323c22b2
+internal/host/rootmovesfloor_source.go readRootProtocolProjection authority F,J4 rev3=PROPOSED-p3;identity=carlos agent in carlos-edge.service uid carlos;basis=checked public root projection must agree with selector and directory sync before advertising acceptance file:Open@86,file:Read@232 36f45f87d3cabebaaf759e2cd2b3c4397a5247247e329a0fd71de291ad1a2bf0
internal/host/rootmovesfloor_source.go refreshRootMovesFloor authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=normal adopt/update combines remote object with independent deny-only legacy evidence under root floor lock a41df2c69e7d869b5232d545440b8135e8c7124d490965e641b8d1fba83cb490
-internal/host/rootmovesfloor_source.go rootFloorLegacy authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=agent-writable mirrors contribute only componentwise denies; malformed sibling cannot erase usable evidence file:ReadAll@710,file:Stat@518 37e37f7b57fb768eea5cf750fb1a88a1a43b4239180878f5479a77f276dce424
+internal/host/rootmovesfloor_source.go rootFloorLegacy authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=selects existing moves mirror paths for denial-only generic legacy reader e5dacb9fa26be5f5f961e99909624f38643f93c43432ff3dce1cff552f7e486a
internal/host/rootmovesfloor_source.go rootMovesFloorIdentity authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=root-only census reader supplies deployment/box/enrollment; edge mode cannot call it 457cdc76e2cff8fdfe4d146c7baf086dd58d54f151ce9416c32efd0a03778d98
-internal/host/rootmovesfloor_source.go withEffectiveMovesFloorLock authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=root census path holds private stable floor lock; no-census path uses unchanged legacy exclusion 9c40f6835a408e2c3e7ad9251a424ae1a03013fe630296cf1a751271da39283b
+internal/host/rootmovesfloor_source.go rootProtocolLegacy authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=bounded agent-writable JSON and line mirrors contribute only increasing denies; neither can establish accepted state file:ReadAll@729,file:Stat@537 0b62a121d683312c34a5c7af486d60d496482ef25502e8e2ef0ef18b5e7c6fd6
+internal/host/rootmovesfloor_source.go withEffectiveMovesFloorLock authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=locks moves then seeds private root stores across final activation; no census uses unchanged legacy lock 752817cc4fc1110aa055324bedb2b9e2b7da7f87445b48c8290ebb53ea3d85d7
+internal/host/rootseedsfloor.go judgeRootSeedsPrev authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=evaluates signed current and prev seeds support under shared durable floor judge and independent downgrade completeness 81b14db048a628e945ba2917e2974a0fcf6d7d81ffc536b5eed4dd0bd046302d
+internal/host/rootseedsfloor.go readEffectiveSeedsFloor authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=uses local root seeds bound only for census participants; no census retains existing mirror read 40bcb02d390a203e39e8565439f6e583151053b9a50fd0aa6ed313aa0bbfe71c
+internal/host/rootseedsfloor.go readRootSeedsBinary authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=delegates checked signed executable read then selects seeds protocol 8b34c01e9a76ddd185202ea9c8542ed0a1c1aca196e26e129507fa03e9610a89
+internal/host/rootseedsfloor.go refreshRootSeedsFloor authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=merges authoritative store floor with denial-only legacy seeds maxima into durable independent root state 8886e3c2017edb222014f10db5e68d9fe14146004cdd193eabaf1600f1f0edec
+internal/host/rootseedsfloor.go rootFloorBinary.forSeeds authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=uses independently signed SeedsProtocol as the protocol compared against seeds bounds b84949e21b9955da9d37594e604f5a4340b5f2bb073866d77feb5b901d3a7de8
+internal/host/rootseedsfloor.go rootRefreshAndJudgeSeedsPrev authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=normal adopt/update refreshes root seeds state under prev exclusion; recovery never calls this remote path d65b77431479fa0989ed6f7a486406f8ba176b6035868e4738b1e14e29ba9782
+internal/host/rootseedsfloor.go rootSeedsAllowsBinary authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=checks signed executable seeds protocol and release against locally retained root bound without network 2a8c7e3c837bbda6ce8073477a7c3c5a217c33a56abb51370cb160ae22ac9658
+internal/host/rootseedsfloor.go rootSeedsFloorDir authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=selects fixed seeds namespace separate from moves retained floor a4971110cfd11c46b8fec4f21e36101bce537014b52a5cd60acce87204fc8f26
+internal/host/rootseedsfloor.go systemApplier.rootProtocolAllowsBinary authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=final installation restore or forward-completion checks signed image against both local root bounds while activation locks are held dac3212012c9ad50e8f2cf4165657c151c561e965c72a3aa0e2f98d9794af353
+internal/host/rootseedsfloor.go systemApplier.seedsActivationRefusal authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=refuses activation below selected local seeds release bound; missing unknown history grants no acceptance projection b45b7ebcbc1f00fb8bb2bdb78b9ddf94af7452426dd794ad42c988f4c921b5a1
+internal/host/rootseedsfloor.go withRootSeedsFields authority F,J4 rev3=PROPOSED-p3;identity=carlos agent in carlos-edge.service uid carlos;basis=advertises only checked synced root seeds projection matching deployment and box; no valid projection stays quarantined file:Open@413 f658ea5051cc904de7154205732a3ec764d24bd230298b8c9bcae0ab4b5b7e47
+internal/host/seedsfloor.go systemApplier.armSeedsFloor authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=census participants refresh root seeds history; nonparticipants retain existing legacy sync ace92bbe72e79e998daeebfe5fc2380ce6174cc0241b60a26e0093bea614bce1
+internal/host/seedsfloor.go withSeedsFields authority F,J4 rev3=PROPOSED-p3;identity=carlos agent in carlos-edge.service uid carlos;basis=root participation selects checked root projection; legacy agent mirror remains unchanged for nonparticipants file:Lstat@84 57febc09559f80119ba36ca13188febf0ceb27bcdc29d829403798aba1cb6f1d
internal/host/systemapply.go RunSystemApply authority D,F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=root system apply takes D3 release exclusion only for census participation before normal apply file:Open@1265 22440d8ad127c66958b9c4d06244db4ca1aec1b9dfd60fad8f5bceb24f789b25
-internal/host/systemapply.go systemApplier.activationGuard authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=rechecks local root floor and root downgrade-idle proof before effects; registry holds add refusal only whole:Route@1083 49988cef27c704d1c937176f1bcad1d22a229beb18a8ef2a2ef949cd39fae9e6
-internal/host/systemapply.go systemApplier.activationRule authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=provisional version/registry refusal check; every effect caller must subsequently pass activationGuard whole:Route@1019,whole:Route@810,whole:Route@817 bbe33714cf851396d775273545cece1aeed399f275bdaf303e126dc724ee0961
+internal/host/systemapply.go systemApplier.activationGuard authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=final activation holds root moves and seeds locks and checks each local bound before effect; main route holds retained on legacy path whole:Route@1074 7a9f97134fcacad2d3aae258dbc40d82c5e19bc36ff2ac75dab4fd55f44e4463
+internal/host/systemapply.go systemApplier.activationRule authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=preliminary activation refuses against both local protocol bounds before existing hold checks whole:Route@1010,whole:Route@801,whole:Route@808 42bfa3076ade44a0b3cf72287eb7fb866bcd51e55e79cd8d933ef17fc24c3ff3
internal/host/systemapply.go systemApplier.armFloor authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=normal update refreshes root floor in census mode; unknown history remains unacknowledged; seeds path pending root conversion d7e16eaaa3994896bf9acd763777cc4d8998056dfa2d492c5320395102009a16
-internal/host/systemapply.go systemApplier.floorAllowsForward authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=forward candidate checked against census local root floor or main legacy floor fc96e9d74e0b9b32778ac4a86363e4095a08d0b11c45e6c1321b6da9082d0f66
-internal/host/systemapply.go systemApplier.floorArmed authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=root retained moves denies determine arming; legacy seeds arming still pending P3-R4 conversion 561ff8cfef8a66cc72ed1baef8729f2b1f513d4fae44acd7cac9b23648b6485c
+internal/host/systemapply.go systemApplier.floorAllowsForward authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=network-free forward recovery checks both locally retained protocol release minima ce69fced288baccce9482897f14b1f221da75f8184ed1008f1ad629dbda64bb1
+internal/host/systemapply.go systemApplier.floorArmed authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=dormancy decision includes unreadable or present moves and seeds floors from selected authority 13a51640e1a6855a4c5b96bee413c73a68f362591e889ed9d3730ac41151456b
+internal/host/systemapply.go systemApplier.install authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=checks signed staged image against both protocol minima inside final locked activation before binary rename ba2335ffabe52f7c89fdf7100c606b5c8d30ff7c6b60fac61b455f24d084865b
internal/host/systemapply.go systemApplier.layoutReversalReasons authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=root participation and retained floor contribute refusal reasons before layout reversal ee4e5ee2961e86b6d2e700601d57bb605c3c945515ffc678d35919d70dc2b0af
-internal/host/systemapply.go systemApplier.notePrev authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=root participants judge signed previous binary against retained state; legacy mode unchanged d55d08f057fb6ea467e293b4e7f0ca3e2e639d26147cab2d1e823530404ff2db
-internal/host/systemapply.go systemApplier.recoverIfNeeded authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=recovery keeps journal until local signed restore/forward checks finish; no remote floor refresh ea7db9e194e0beed0bba492f08a96ab6a46f2f9dc1e35d8e30abe9d0733d664c
-internal/host/systemapply.go systemApplier.restoreFiles authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=local signed previous identity supplies rollback version before copy/rename; never reads remote store file:Stat@226 85c25d0ff4f3e83f967f55afc39ed1f58e050285fc03eb56d70fc8fab0f9caff
+internal/host/systemapply.go systemApplier.notePrev authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=writes prev then judges both signed protocol identities under root floor authority on census boxes 2642cc413f5eb6a3f5661eff6452798c296c1199037f4699d5fff0fa00ca6335
+internal/host/systemapply.go systemApplier.recoverIfNeeded authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=network-free recovery uses local root floors and rechecks signed image at final forward-completion boundary c15e47720b641beee468a0d2a52d2a8e2b7546f47b3ee720d1bc315067854c41
+internal/host/systemapply.go systemApplier.restoreFiles authority F,J4 rev3=PROPOSED-p3;identity=carlos adopt/system apply uid 0; system recovery and guard selectors uid 0;basis=selects signed prev identity and checks staged restore against both root bounds before rename; version sidecar cannot authorize file:Stat@226 024ce4b2a5e65b76f2a6506d8b0022f99bca73959d942300ed21d89bf517fd3f
internal/host/systemapply.go systemApplier.restoreVersion authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=census rollback and recovery completion use signed previous executable identity; legacy sidecar remains only without census 57cf91fc81568e5b1ce05428ed928242c825c6847bb193333137845c7190d6c4
internal/host/systemapply.go systemApplier.verifyPointer authority F,J4 rev3=PROPOSED-p3;identity=carlos system apply/recover and root adopt, systemd root units or offline CLI, uid 0;basis=signed pointer verified and independently cached for census target execution before earlier history exists 50961a77b1c9b4e915302c6b95cd57d9f0bbc238e7bd00b94f06a5960ab541c1
internal/host/units/carlos-system-recover.service external authority F,J4 rev3=PROPOSED-p3;identity=systemd update/recover oneshot shell uid 0;basis=guard branch is conditional on executable presence; unarmed units retain main selector; installed guard selects from root local floor file:external@0 f16b260868c4b258c804b3a4720e17f02f6c020eda9dcc2a67d2ca032a077b45
internal/host/units/carlos-system-update.service external authority F,J4 rev3=PROPOSED-p3;identity=systemd update/recover oneshot shell uid 0;basis=guard branch is conditional on executable presence; unarmed units retain main selector; installed guard selects from root local floor file:external@0 cfcaeea6308021ce5a7cb088464e8df3abc6b03ffaa0c7a0153c6d91bf1e3fd6
internal/hostbox/migration_census_linux.go CheckMigrationRoots authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=offline scan requires every managed payload to match independent retained inode/type inventory or explicit shared path rules file:Lstat@1678,file:Lstat@932,file:WalkDir@1202 3eb5e56f13b0932364b68c7d80ad18135b262614b105c41bfcc3477d6f558e79
internal/hostbox/migration_census_other.go CheckMigrationRoots authority G,J4 rev3=PROPOSED-p3;identity=carlos system journal-migrate, offline operator CLI, uid 0;basis=offline scan requires every managed payload to match independent retained inode/type inventory or explicit shared path rules 594ecda260fcf80baf5a2709c9cc5a04e91c655c8bd2f7ac307eef44216eb9c8
-internal/hostguard/guard.go record.admits authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=existing lease checks additionally refuse migration-retired sessions 3e0a40e659744b48ed8aa3f3f6cd367d45098bbcbcffba4c6876d62e34856f3f
-internal/hostguard/root.go Guard.CommitRootInventoryArtifacts authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=retained migration deny refuses inventory commit for an old session before authority transfer edd70bbf6c152e17d73a39fb7cd0d6ebf7382ae2bf1f80160f3ddc2c4c827937
+internal/hostguard/guard.go record.admits authority G,J4 rev3=PROPOSED-p3;identity=root claim controller construction uid 0;basis=root admission retains migration denial until the exact predecessor session has a completed retained receipt 5864d1fa79a66ca6f69057416079b46524ed4bfdf1ce1de88124b5a24a428418
+internal/hostguard/root.go Guard.CommitRootInventoryArtifacts authority G,J4 rev3=PROPOSED-p3;identity=root claim controller construction uid 0;basis=root inventory commit admits a newer migration writer only after its exact predecessor completed; old sessions stay denied 08e42f8810e2645a875b5177fc9e104ed4b307c7d80dc77d204e2516b1bd66de
internal/hostguard/root.go RootAuthority.read authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=strict checked root record decoding includes retained migration session denials file:Read@175 7ff862f573a05a17951546a56ff791d42962d693331a69e8338bd8eb4f283d6b
internal/hostguard/root.go RootAuthority.write authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=retains migration denials atomically with the existing root installation, fence and retired-state record 678156528bb5d3e92f8c07835ff7a7d05afb5d498ff604966d2b6d9293867c0c
internal/hostguard/rootmigration.go RootAuthority.CompleteMigrationInventory authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=root completed fence receipt and retained session deny required before installing recovered inventory revision a548470f72eb9f5f9a457c292e06a303f4a7dca2068f10eda5fcbb4e118ab783
internal/hostguard/rootmigration.go RootAuthority.RetainMigrationDeny authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=root retains old incarnation/stamp/session denial under stable per-host lock; grants no launch authority fb821d19b6ca6e1454d025013d27ef8a856a684262421d3ee76804723e875c37
+internal/hostguard/rootmigration.go migrationAdmissionDenied authority G,J4 rev3=PROPOSED-p3;identity=root claim controller construction uid 0;basis=old migrated sessions remain denied; a newer same-installation session requires completed receipt for every retained migration predecessor c7312f5e0e67b2b69bbea1b8f432dd48ba06f0b4a26ebf349b6724769651631e
internal/hostguard/rootmigration.go migrationDenied authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=old sessions at/below retained captured stamp refuse root and agent admission 6ccf5c00e33d880aa50ea15d2bce22a3cce14455d5cc7f05197e42f789da8805
internal/hostguard/rootmigration_inventory.go RootAuthority.MigrationInstallations authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=read-only root installation enumeration includes incomplete records and never repairs before preflight ad855b461cb12e5499ecec74b210f6f41a758a3ed36c0eac0ef5931bc7fad6c2
-internal/hostguard/rootview.go FenceView.admit authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=agent view applies the same retained root migration session denial f512598847c55a6c585206dc8e1daf7f6dd8625c4d408795ffe72ef3a442982a
+internal/hostguard/rootview.go FenceView.admit authority G,J4 rev3=PROPOSED-p3;identity=root claim controller construction uid 0;basis=read-only root view applies same predecessor-completion requirement as locked admission 5bcc6b69bb3e98a1bda6ace2b0b2a147f8c39b61601c609c30e3fb806231efae
internal/hostguard/rootview.go Guard.fenceLocked authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=projects root-only migration denials into agent admission snapshot without trusting local hostguard records 290295ed69e06548a41e101317098ae2d6aac10c41b260e120af55860686a96d
internal/hostguard/rootview.go RootAuthority.viewLocked authority C,G,J4 rev3=PROPOSED-p3;identity=carlos instance-control, root control service uid 0; denial views also consumed by carlos agent uid carlos;basis=projects root migration denials together with canonical installation and existing fences 5db6824b38f6ad3e4072008ffa3ff9fab98db6a5d40ecba59ca30900e331a46f
internal/rootstate/fault_rootrunner_linux.go WithWriteFault diagnostic A,F rev3=PROPOSED-p3;identity=rootrunner test binary uid 0 only;basis=rootrunner-only wrapper injects failures into the same validated production write steps; absent from shipped binary 31c00dbe4dd1441867d2a1184598be8a8fc5aa743e6f4227968b3b7f50d9c11d
@@ -91,11 +121,12 @@ internal/rootstate/state_linux.go directory.write authority A,F,G rev3=PROPOSED-
internal/safefile/fault_rootrunner_linux.go WriteAtFault diagnostic A,F rev3=PROPOSED-p3;identity=rootrunner test binary uid 0 only;basis=rootrunner-only seam runs actual safefile operations before/after chosen interruption dff2fd53a3561df3aa04b6f7462ab62c4cc32c826d152464989b6a399d83056c
internal/safefile/file_linux.go writeAt authority A,F,G rev3=PROPOSED-p3;identity=checked filesystem callers including root control/apply/migration uid 0;basis=production wrapper always invokes each real syscall operation eadb1d4dd961ebc7dc7ba8a469669003695d618d6c0dc13794763c7fa881d22c
internal/safefile/file_linux.go writeAtSteps authority A,F,G rev3=PROPOSED-p3;identity=checked filesystem callers including root control/apply/migration uid 0;basis=checked staged file write, chmod, sync, close, rename and parent sync propagate interruptions before acknowledgement file:Fstat@1116,file:Fstat@700,file:Openat@522,file:Openat@875,file:Read@277 865b847b97df6753e1abd16a00d018d27cdabdd23c96d42a97b381103d088d21
-@coverage internal/console 5ce330f276a80d2ae901698ac81c7e2861f6363ba70aa1e5f9164b76c0c03bd8
-@coverage internal/edgesfile 46558f38ee9cef8def5a1915a2545e91795ede008b4b296f102a7ffd45dd1b82
-@coverage internal/host 92c9b29e256e6afa8fe90457752c2dc86f3cecb2340af0f655d097bef164a885
+@coverage internal/console 6f4998c39e6d66cd73ea98500000cbecac251dd21dafd58f51a85c2291e0c533
+@coverage internal/edgesfile 7b48368150a427e4620f1c9ded49942b688c9e6c9cd983b7da43d3f2c0810b72
+@coverage internal/host 1f1b2f8b947763fe600c432a8100f4b999fafb3b4da1913af8555d55cc090219
@coverage internal/host/units bf6ae516012701e0ca41908167e4efd60dd3c2c24e9af1555dec077e8be44461
@coverage internal/hostbox 85594e031100a619d3bae5e555dcda2e496bdc4d91d0a53b0f4da0dbe6bbd261
-@coverage internal/hostguard f97ec930b80a6b09fec120e4b25f17a01012400dcf433e96586a4140b6db2c63
+@coverage internal/hostguard 98971d1a09a9c32d644752d441ae9e7072ac17352754e733cb6fa30eeeea218c
+@coverage internal/release df0c381af44576fc3d30648e6dbfdad63fb2f56d6f9add4be7ead8e34b66b451
@coverage internal/rootstate 8415892a08d6c9c1b2d71aa25b0952cbcaa53e533213b7fac56dd0666ee787cf
@coverage internal/safefile 7030f312f7a60487381f0dbae2cdecaaa83625b639b6af15104f912592550816
diff --git a/internal/release/systempointer.go b/internal/release/systempointer.go
index 569794229..117f5af25 100644
--- a/internal/release/systempointer.go
+++ b/internal/release/systempointer.go
@@ -37,6 +37,7 @@ type SystemPointer struct {
type SystemRelease struct {
ConsoleArtifacts map[string]SystemArtifact `json:"console_artifacts,omitempty"`
ReleaseTag string `json:"release_tag,omitempty"`
+ SeedsProtocol int `json:"seeds_protocol,omitempty"`
MovesProtocol int `json:"moves_protocol,omitempty"`
Version string `json:"version"`
Artifacts map[string]SystemArtifact `json:"artifacts"` // keyed by "<goos>-<goarch>"