carlos / platform Public

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

F2-F4: root moves floor persistence and reconstruction, refresh with both prev branches, both generated boot selectors; G1 audited; 47 PROPOSED-p3 census rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 097f2dd2dfa19a0ec5c2bfe8e9d3412ea3f59721 parent bd87f35
30 files changed, +2227 −17
  • internal/console/boxselect.go +1 −1
  • internal/console/boxselect_test.go +3 −0
  • internal/console/plan14_root_floor_test.go +57 −0
  • internal/edgesfile/snapshot.go +2 −0
  • internal/host/edgesfile.go +2 −0
  • internal/host/guardarm.go +18 −0
  • internal/host/guardexec.go +19 −0
  • internal/host/guardtransaction.go +16 −0
  • internal/host/movesfloor.go +20 −1
  • internal/host/movesfloorgate.go +3 −0
  • internal/host/rootmovesfloor.go +293 −0
  • internal/host/rootmovesfloor_boot.go +112 −0
  • internal/host/rootmovesfloor_boot_root_linux_test.go +248 −0
  • internal/host/rootmovesfloor_boot_test.go +83 −0
  • internal/host/rootmovesfloor_identity.go +36 −0
  • internal/host/rootmovesfloor_refresh.go +259 −0
  • internal/host/rootmovesfloor_refresh_test.go +123 −0
  • internal/host/rootmovesfloor_root_linux_test.go +362 −0
  • internal/host/rootmovesfloor_source.go +202 −0
  • internal/host/rootmovesfloor_test.go +184 −0
  • internal/host/seedsfloor_test.go +4 −4
  • internal/host/systemapply.go +78 −8
  • internal/host/units/carlos-system-recover.service +1 −1
  • internal/host/units/carlos-system-update.service +1 −1
  • internal/provision/plan14_p3_census_test.go +27 −0
  • internal/provision/plan14_root_reads_test.go +3 −0
  • internal/provision/plan9_b8_census_test.go +1 −1
  • internal/provision/testdata/plan14-root-reads-p3.tsv +54 −0
  • internal/rootstate/state.go +9 −0
  • internal/rootstate/state_linux.go +6 −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	285.757s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.777s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.475s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.721s
ok  	github.com/carlosframework/platform/internal/activator	250.238s
ok  	github.com/carlosframework/platform/internal/agentsock	0.013s
ok  	github.com/carlosframework/platform/internal/alerting	0.004s
ok  	github.com/carlosframework/platform/internal/analytics	0.018s
ok  	github.com/carlosframework/platform/internal/appconfig	2.496s
ok  	github.com/carlosframework/platform/internal/apperr	0.076s
ok  	github.com/carlosframework/platform/internal/applog	0.129s
ok  	github.com/carlosframework/platform/internal/awsinv	0.004s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.679s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.008s
ok  	github.com/carlosframework/platform/internal/caps	0.011s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.704s
ok  	github.com/carlosframework/platform/internal/changelog	0.355s
ok  	github.com/carlosframework/platform/internal/clamd	0.054s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	389.056s
?   	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.720s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.012s
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.355s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	26.726s
?   	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.020s
ok  	github.com/carlosframework/platform/internal/geo	0.024s
ok  	github.com/carlosframework/platform/internal/geoip	0.030s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.163s
ok  	github.com/carlosframework/platform/internal/host	143.065s
ok  	github.com/carlosframework/platform/internal/hostactors	0.411s
ok  	github.com/carlosframework/platform/internal/hostbox	2.363s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.598s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.006s
ok  	github.com/carlosframework/platform/internal/hostexec	5.195s
ok  	github.com/carlosframework/platform/internal/hostfence	0.035s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.525s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.012s
ok  	github.com/carlosframework/platform/internal/hostguard	1.038s
ok  	github.com/carlosframework/platform/internal/hostops	1.679s
?   	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.501s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.163s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.002s
ok  	github.com/carlosframework/platform/internal/instancectl	4.173s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.770s
ok  	github.com/carlosframework/platform/internal/instancefs	0.006s
ok  	github.com/carlosframework/platform/internal/instancekey	0.015s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.021s
ok  	github.com/carlosframework/platform/internal/layout	1.558s
ok  	github.com/carlosframework/platform/internal/lease	9.823s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.005s
ok  	github.com/carlosframework/platform/internal/ledger	0.013s
ok  	github.com/carlosframework/platform/internal/mail	0.192s
ok  	github.com/carlosframework/platform/internal/maintenance	0.081s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.535s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.110s
ok  	github.com/carlosframework/platform/internal/metrics	0.007s
ok  	github.com/carlosframework/platform/internal/moves	21.070s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.822s
ok  	github.com/carlosframework/platform/internal/objstore	0.196s
ok  	github.com/carlosframework/platform/internal/ops	0.174s
ok  	github.com/carlosframework/platform/internal/park	0.252s
ok  	github.com/carlosframework/platform/internal/privatefile	0.007s
ok  	github.com/carlosframework/platform/internal/provision	17.346s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.007s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.448s
ok  	github.com/carlosframework/platform/internal/relay	7.245s
ok  	github.com/carlosframework/platform/internal/release	21.511s
ok  	github.com/carlosframework/platform/internal/rollout	3.922s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.004s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.009s
ok  	github.com/carlosframework/platform/internal/rootstate	0.027s
ok  	github.com/carlosframework/platform/internal/runs	0.586s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.530s
ok  	github.com/carlosframework/platform/internal/seeds	0.015s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.899s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.995s
ok  	github.com/carlosframework/platform/internal/status	0.041s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.298s
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.007s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.002s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.620s
=== 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: 47120546 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.022s
=== RUN   TestT4StorageProbeMinio
2026/10/15 13:49:18 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEB82227785725, 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.00s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.020s
minio contract: PASS
diff --git a/internal/console/boxselect.go b/internal/console/boxselect.go
index 605ff0a2e..82bd519a2 100644
--- a/internal/console/boxselect.go
+++ b/internal/console/boxselect.go
@@ -102,7 +102,7 @@ func (s *Server) selectMoveBoxes(ctx context.Context, host, to, from, app string
}
if floor != nil {
prevOK := f.PrevRelease == "none" || f.PrevAllowedFor == floor.MinRelease
- if f.MovesFloorMinRelease != floor.MinRelease || !prevOK {
+ if f.MovesFloorState != "accepted" || f.MovesFloorGeneration == 0 || f.MovesFloorMinRelease != floor.MinRelease || !prevOK {
sel.FloorOK = false
}
}
diff --git a/internal/console/boxselect_test.go b/internal/console/boxselect_test.go
index 0c3ee66d2..21680f0f8 100644
--- a/internal/console/boxselect_test.go
+++ b/internal/console/boxselect_test.go
@@ -104,6 +104,7 @@ func armMoveFleet(t *testing.T, fx *consoleFixture, boxes ...fleetBox) *boxidtes
"hibernation_configured": true, "serves_shared_pool": true, "accepts_inbound": true,
"secrets_key_fp": testKeyFP, "capabilities": []string{"region-ha-1"}, "hosts": map[string]any{},
"moves_protocol": moves.ProtocolVersion, "prev_release": "none",
+ "moves_floor_state": "accepted", "moves_floor_generation": 1,
}
if b.Protocol != 0 {
m["moves_protocol"] = b.Protocol
@@ -113,6 +114,8 @@ func armMoveFleet(t *testing.T, fx *consoleFixture, boxes ...fleetBox) *boxidtes
}
if floor != nil && !b.FloorUnarmed {
m["moves_floor_min_release"] = floor.MinRelease
+ m["moves_floor_state"] = "accepted"
+ m["moves_floor_generation"] = 1
}
if b.PrevRelease != "" {
m["prev_release"] = b.PrevRelease
diff --git a/internal/console/plan14_root_floor_test.go b/internal/console/plan14_root_floor_test.go
new file mode 100644
index 000000000..7e2e22024
--- /dev/null
+++ b/internal/console/plan14_root_floor_test.go
@@ -0,0 +1,57 @@
+package console
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/moves"
+)
+
+func TestPlan14FloorRiseWithoutPlatformUpdate(t *testing.T) {
+ for _, state := range []string{"accepted", "missing", "quarantined", "old generation", "old floor", "old prev", "compatible prev"} {
+ t.Run(state, func(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.rel.Put(t.Context(), moves.FloorKey, []byte(`{"min":1,"min_release":"v2.0.0"}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box"))
+ key := edgesFilePrefix + "uk-box.json"
+ raw, etag, err := fx.rel.Get(t.Context(), key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m map[string]any
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatal(err)
+ }
+ switch state {
+ case "missing":
+ delete(m, "moves_floor_state")
+ case "quarantined":
+ m["moves_floor_state"] = "quarantined"
+ case "old generation":
+ m["moves_floor_generation"] = 0
+ case "old floor":
+ m["moves_floor_min_release"] = "v1.0.0"
+ case "old prev":
+ m["prev_release"] = "v1.0.0"
+ m["prev_allowed_for"] = "v1.0.0"
+ case "compatible prev":
+ m["prev_release"] = "v2.0.0"
+ m["prev_allowed_for"] = "v2.0.0"
+ }
+ raw, _ = json.Marshal(m)
+ if _, err := fx.rel.Put(t.Context(), key, raw, etag); err != nil {
+ t.Fatal(err)
+ }
+ got, err := fx.srv.selectMoveBoxes(t.Context(), "acme.go.tito.io", "uk", "ie", "bab/titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := state == "accepted" || state == "compatible prev"
+ if got.FloorOK != want {
+ t.Fatalf("FloorOK=%v want %v", got.FloorOK, want)
+ }
+ })
+ }
+}
diff --git a/internal/edgesfile/snapshot.go b/internal/edgesfile/snapshot.go
index 10308ccbb..7efff95fb 100644
--- a/internal/edgesfile/snapshot.go
+++ b/internal/edgesfile/snapshot.go
@@ -72,6 +72,8 @@ type PlacementView struct {
// carlos.prev was judged at or above.
MovesProtocol int `json:"moves_protocol"`
MovesFloorMinRelease string `json:"moves_floor_min_release"`
+ MovesFloorState string `json:"moves_floor_state,omitempty"`
+ MovesFloorGeneration uint64 `json:"moves_floor_generation,omitempty"`
PrevRelease string `json:"prev_release"`
PrevAllowedFor string `json:"prev_allowed_for"`
// The seeds floor (seeded-instances spec §3.3), read only by the
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go
index bc848fa75..d8689a643 100644
--- a/internal/host/edgesfile.go
+++ b/internal/host/edgesfile.go
@@ -296,6 +296,8 @@ type edgesFile struct {
// MovesFloorMinRelease is the min_release of the floor armed on this box
// (<DataDir>/.moves/floor.min-release); absent: none armed.
MovesFloorMinRelease string `json:"moves_floor_min_release,omitempty"`
+ MovesFloorState string `json:"moves_floor_state,omitempty"`
+ MovesFloorGeneration uint64 `json:"moves_floor_generation,omitempty"`
// PrevRelease is carlos.prev's release (carlos.prev.version); "none"
// when there is no carlos.prev; absent when it is unknown.
PrevRelease string `json:"prev_release,omitempty"`
diff --git a/internal/host/guardarm.go b/internal/host/guardarm.go
index e9c8d5665..6a37a8919 100644
--- a/internal/host/guardarm.go
+++ b/internal/host/guardarm.go
@@ -129,6 +129,24 @@ func RunSystemGuardArm(args []string) {
log.Fatal(err)
}
}
+ // A migrated box retains its recovery checker until the root floor and
+ // selector are durable. No-census arming keeps its original ordering.
+ if id, required, e := rootMovesFloorIdentity(); e != nil {
+ log.Fatal(e)
+ } else if required {
+ projection, e := readRootFloorProjection(release.OptDir())
+ if e != nil || projection.Identity != id {
+ log.Fatal(errRootFloorHistory)
+ }
+ signed, e := release.VerifySystemIdentity(d.PublicKey, identity)
+ if e != nil {
+ log.Fatal(e)
+ }
+ image := rootFloorBinary{Version: signed.Current.ReleaseTag, Protocol: signed.Current.MovesProtocol, Digest: hash}
+ if !image.supports(projection.Floor) {
+ log.Fatal(errRootFloorHistory)
+ }
+ }
// The checker is outside system/router and outside every restore set.
if err = copyFileDurable("/proc/self/exe", filepath.Join(d.Dir, "carlos"), 0755); err != nil {
log.Fatal(err)
diff --git a/internal/host/guardexec.go b/internal/host/guardexec.go
index a5c1bdb3f..ddc2268ef 100644
--- a/internal/host/guardexec.go
+++ b/internal/host/guardexec.go
@@ -85,6 +85,25 @@ func RunSystemGuardExec(args []string) {
default:
log.Fatal(hostfloor.ErrFloor)
}
+ if component == "router" {
+ id, required, e := rootMovesFloorIdentity()
+ if e != nil {
+ log.Fatal(e)
+ }
+ if required {
+ journal, e := rootFloorJournalPresent(release.OptDir())
+ if e != nil {
+ log.Fatal(e)
+ }
+ e = withRootFloorGuardCandidate(context.Background(), release.OptDir(), id, journal && len(args) > 1 && args[0] == "system", func(selected string) error {
+ return d.Exec(selected, runtime.GOOS+"-"+runtime.GOARCH, args, os.Environ(), component)
+ })
+ if e != nil {
+ log.Fatal(e)
+ }
+ return
+ }
+ }
if err = d.Exec(candidate, runtime.GOOS+"-"+runtime.GOARCH, args, os.Environ(), component); err != nil {
log.Fatal(err)
}
diff --git a/internal/host/guardtransaction.go b/internal/host/guardtransaction.go
index 30ef1013d..067bfaf5b 100644
--- a/internal/host/guardtransaction.go
+++ b/internal/host/guardtransaction.go
@@ -358,6 +358,22 @@ func (a *systemApplier) closeGuardJournal() error {
return a.endGuard()
}
func (a *systemApplier) guardRun(ctx context.Context, wait time.Duration, name string, args ...string) (string, error) {
+ if _, required, err := a.rootMoves.read(); err != nil {
+ return "", err
+ } else if required {
+ binary, err := readRootFloorBinary(a.optDir, name)
+ if err != nil {
+ return "", err
+ }
+ floor, err := readEffectiveMovesFloor(a.rootMoves, a.optDir, a.dataDir)
+ if err != nil {
+ return "", err
+ }
+ if !binary.supports(floor) {
+ return "", errRootFloorHistory
+ }
+ }
+
if !a.guarded() {
return a.ops.run(ctx, wait, name, args...)
}
diff --git a/internal/host/movesfloor.go b/internal/host/movesfloor.go
index 081203653..7c38d9b0e 100644
--- a/internal/host/movesfloor.go
+++ b/internal/host/movesfloor.go
@@ -454,7 +454,11 @@ func withPrevLock(optDir string, fn func() error) error {
// half, then the seeds half (seedsfloor.go). Each runs whatever the other
// returns.
func rootJudgePrev(reg *registry.Registry) error {
- return errors.Join(rootJudgeMovesPrev(reg), rootJudgeSeedsPrev())
+ return rootJudgePrevWithSource(reg, rootMovesFloorIdentity)
+}
+
+func rootJudgePrevWithSource(reg *registry.Registry, source rootFloorIdentitySource) error {
+ return errors.Join(rootJudgeMovesPrevWithSource(reg, source), rootJudgeSeedsPrev())
}
// rootJudgeMovesPrev is the moves half: the same judge decidePrev runs,
@@ -462,6 +466,15 @@ func rootJudgePrev(reg *registry.Registry) error {
// the sidecar is missing (a box whose carlos.prev was installed by a build
// before the floor existed).
func rootJudgeMovesPrev(reg *registry.Registry) error {
+ return rootJudgeMovesPrevWithSource(reg, rootMovesFloorIdentity)
+}
+func rootJudgeMovesPrevWithSource(reg *registry.Registry, source rootFloorIdentitySource) error {
+ if id, required, err := source.read(); err != nil {
+ return err
+ } else if required {
+ return rootRefreshAndJudgeMovesPrev(id)
+ }
+
optDir, dd := release.OptDir(), dataDir()
if _, err := os.Stat(filepath.Join(dd, moves.FloorFile)); errors.Is(err, os.ErrNotExist) {
// No floor armed: nothing to judge and no verdict to keep. The one
@@ -508,6 +521,12 @@ func movesFloorLoop(ctx context.Context, s release.Store, dataDir string) {
// there is no carlos.prev; absent: unknown) and the verdict (absent: no
// verdict, which the door reads as below the floor).
func withMovesFields(f edgesFile, dataDir, optDir string) edgesFile {
+ // The public root marker is the agent's mode indication; it cannot inspect
+ // the private census. G2 installs this marker before completing migration.
+ if _, err := os.Lstat(rootFloorDir(optDir)); !errors.Is(err, os.ErrNotExist) {
+ return withRootMovesFields(f, optDir)
+ }
+
f.MovesProtocol = moves.ProtocolVersion
// From floor.json, the file every Go reader trusts: floor.min-release
// is written first and can be ahead of it (writeFloorFiles).
diff --git a/internal/host/movesfloorgate.go b/internal/host/movesfloorgate.go
index 075856a4c..336d157c9 100644
--- a/internal/host/movesfloorgate.go
+++ b/internal/host/movesfloorgate.go
@@ -67,6 +67,9 @@ func RunSystemFloorAllowsPrev(args []string) {
// carlos.prev may not run. It returns the exit code.
func floorGateMain(args []string, floorOwners, verdictOwners []uint32, stderr io.Writer) int {
err := errFloorGateUsage
+ if len(args) == 2 && args[0] == "--root" {
+ err = rootFloorAllowsPrev(args[1])
+ }
if len(args) == 2 && filepath.IsAbs(args[0]) && filepath.IsAbs(args[1]) {
err = floorAllowsPrev(args[0], args[1], floorOwners, verdictOwners)
}
diff --git a/internal/host/rootmovesfloor.go b/internal/host/rootmovesfloor.go
new file mode 100644
index 000000000..537bb00d0
--- /dev/null
+++ b/internal/host/rootmovesfloor.go
@@ -0,0 +1,293 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/rootparticipation"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+const (
+ rootFloorAccepted = "accepted.json"
+ rootFloorDeny = "deny.json"
+ rootFloorSelector = "floor.line"
+ rootFloorProjectionFile = "accepted.json"
+)
+
+type rootFloorIdentity struct{ Deployment, Box, Enrollment string }
+type rootFloorReconstruction struct {
+ Identity rootFloorIdentity
+ Floor *moves.Floor
+ NeverArmed bool
+ Source, Digest, Authorization string
+}
+type rootFloorRecord struct {
+ Schema int
+ Identity rootFloorIdentity
+ Generation uint64
+ Floor *moves.Floor
+ NeverArmed bool
+ Provenance rootFloorReconstruction
+}
+type rootFloorProjection struct {
+ rootFloorRecord
+ Accepted bool
+}
+type rootMovesFloors struct {
+ private, public rootstate.Store
+ identity rootFloorIdentity
+}
+
+func rootFloorDir(opt string) string { return filepath.Join(opt, "system", "guard", "moves") }
+func openRootMovesFloors(opt string, id rootFloorIdentity, create bool) (rootMovesFloors, error) {
+ f := rootMovesFloors{identity: id}
+ var err error
+ f.public, err = rootstate.Open(rootFloorDir(opt), create, true)
+ if err != nil {
+ return f, err
+ }
+ f.private, err = rootstate.Open(filepath.Join(rootFloorDir(opt), "private"), create, false)
+ if err != nil {
+ f.public.Close()
+ return rootMovesFloors{}, err
+ }
+ return f, nil
+}
+func (f rootMovesFloors) close() { f.private.Close(); f.public.Close() }
+func validRootFloor(fl *moves.Floor) bool {
+ return fl != nil && fl.Min > 0 && validFloorRelease(fl.MinRelease)
+}
+func (r rootFloorRecord) valid(id rootFloorIdentity) bool {
+ return r.Schema == 1 && r.Identity == id && id.Deployment != "" && id.Box != "" && id.Enrollment != "" && r.Generation > 0 && ((validRootFloor(r.Floor) && !r.NeverArmed) || (r.Floor == nil && r.NeverArmed))
+}
+func (f rootMovesFloors) record(name string) (rootFloorRecord, error) {
+ var r rootFloorRecord
+ raw, err := f.private.Read(name)
+ if err != nil {
+ return r, err
+ }
+ if len(raw) > movesFloorMaxBytes || rootparticipation.Decode(raw, &r) != nil || !r.valid(f.identity) {
+ return r, errRootFloorHistory
+ }
+ return r, nil
+}
+func floorMaximum(a, b *moves.Floor) *moves.Floor {
+ if a == nil {
+ if b == nil {
+ return nil
+ }
+ c := *b
+ return &c
+ }
+ c := *a
+ if b != nil {
+ if b.Min > c.Min {
+ c.Min = b.Min
+ }
+ if at, _ := moves.ReleaseAtOrAbove(b.MinRelease, c.MinRelease); at {
+ c.MinRelease = b.MinRelease
+ }
+ }
+ return &c
+}
+func rootFloorLine(fl *moves.Floor) []byte {
+ if fl == nil {
+ return []byte("0 none\n")
+ }
+ return []byte(fmt.Sprintf("%d %s\n", fl.Min, fl.MinRelease))
+}
+func (f rootMovesFloors) selector() (*moves.Floor, error) {
+ raw, err := f.public.Read(rootFloorSelector)
+ if err != nil {
+ return nil, err
+ }
+ fields := strings.Fields(string(raw))
+ if len(raw) > 256 || len(fields) != 2 {
+ return nil, errRootFloorHistory
+ }
+ if string(raw) == "0 none\n" {
+ return nil, nil
+ }
+ min, err := strconv.Atoi(fields[0])
+ fl := &moves.Floor{Min: min, MinRelease: fields[1]}
+ if err != nil || !validRootFloor(fl) || string(raw) != string(rootFloorLine(fl)) {
+ return nil, errRootFloorHistory
+ }
+ return fl, nil
+}
+
+// A selector visible after an interrupted write remains a deny, even when the
+// accepted JSON still names the preceding generation.
+func (f rootMovesFloors) bound() (*moves.Floor, error) {
+ var bound *moves.Floor
+ var errs []error
+ for _, name := range []string{rootFloorAccepted, rootFloorDeny} {
+ r, err := f.record(name)
+ if err == nil {
+ bound = floorMaximum(bound, r.Floor)
+ } else if !errors.Is(err, os.ErrNotExist) {
+ errs = append(errs, err)
+ }
+ }
+ fl, err := f.selector()
+ if err == nil {
+ bound = floorMaximum(bound, fl)
+ } else if !errors.Is(err, os.ErrNotExist) {
+ errs = append(errs, err)
+ }
+ return bound, errors.Join(errs...)
+}
+func (f rootMovesFloors) quarantine(cause error) error {
+ raw, _ := json.Marshal(rootFloorProjection{Accepted: false})
+ return errors.Join(cause, f.public.Write(rootFloorProjectionFile, raw))
+}
+func (f rootMovesFloors) projection() (rootFloorProjection, error) {
+ var p rootFloorProjection
+ raw, err := f.public.Read(rootFloorProjectionFile)
+ if err != nil {
+ return p, err
+ }
+ if len(raw) > movesFloorMaxBytes || rootparticipation.Decode(raw, &p) != nil || !p.Accepted || !p.rootFloorRecord.valid(f.identity) {
+ return p, errRootFloorHistory
+ }
+ bound, err := f.selector()
+ if err != nil || string(rootFloorLine(bound)) != string(rootFloorLine(p.Floor)) {
+ return p, errRootFloorHistory
+ }
+ if f.private != nil {
+ accepted, err := f.record(rootFloorAccepted)
+ if err != nil || accepted.Generation != p.Generation || accepted.Identity != p.Identity || string(rootFloorLine(accepted.Floor)) != string(rootFloorLine(p.Floor)) {
+ return p, errRootFloorHistory
+ }
+ bound, err := f.bound()
+ if err != nil || string(rootFloorLine(bound)) != string(rootFloorLine(p.Floor)) {
+ return p, errRootFloorHistory
+ }
+ }
+ return p, nil
+}
+func (f rootMovesFloors) writeRecord(name string, r rootFloorRecord) error {
+ raw, err := json.Marshal(r)
+ if err != nil {
+ return err
+ }
+ return f.private.Write(name, raw)
+}
+
+// Caller holds the stable private floor lock. Every retry rewrites and syncs
+// all prerequisites; visible bytes are never taken as proof of an earlier sync.
+func (f rootMovesFloors) persist(r rootFloorRecord, bound *moves.Floor, accept bool) error {
+ if err := f.quarantine(nil); err != nil {
+ return err
+ }
+ if err := f.public.Write(rootFloorSelector, rootFloorLine(bound)); err != nil {
+ return err
+ }
+ deny := r
+ deny.Floor = bound
+ deny.NeverArmed = bound == nil
+ if err := f.writeRecord(rootFloorDeny, deny); err != nil {
+ return err
+ }
+ if !accept {
+ return errRootFloorHistory
+ }
+ if err := f.writeRecord(rootFloorAccepted, r); err != nil {
+ return err
+ }
+ raw, _ := json.Marshal(rootFloorProjection{rootFloorRecord: r, Accepted: true})
+ if err := f.public.Write(rootFloorProjectionFile, raw); err != nil {
+ return f.quarantine(err)
+ }
+ return nil
+}
+
+// This entry is only for a root-authorized complete reconstruction, including
+// an explicit never-armed attestation. Normal refresh cannot manufacture it.
+func (f rootMovesFloors) reconstruct(ctx context.Context, r rootFloorReconstruction) error {
+ unlock, err := f.private.Lock(ctx, "floor.lock")
+ if err != nil {
+ return err
+ }
+ defer unlock()
+ if r.Identity != f.identity || r.Source == "" || len(r.Digest) != 64 || r.Authorization == "" || (r.NeverArmed != (r.Floor == nil)) || (r.Floor != nil && !validRootFloor(r.Floor)) {
+ return f.quarantine(errRootFloorHistory)
+ }
+ bound, err := f.bound()
+ if err != nil {
+ return f.quarantine(err)
+ }
+ if bound != nil && (r.Floor == nil || floorRegression(bound, r.Floor) != nil) {
+ return f.quarantine(errRootFloorHistory)
+ }
+ generation := uint64(1)
+ old, err := f.record(rootFloorAccepted)
+ if err == nil {
+ generation = old.Generation + 1
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return f.quarantine(err)
+ }
+ next := rootFloorRecord{Schema: 1, Identity: f.identity, Generation: generation, Floor: r.Floor, NeverArmed: r.NeverArmed, Provenance: r}
+ if !next.valid(f.identity) {
+ return f.quarantine(errRootFloorHistory)
+ }
+ return f.persist(next, floorMaximum(bound, r.Floor), true)
+}
+
+func (f rootMovesFloors) refresh(ctx context.Context, remote *moves.Floor, legacy []*moves.Floor, readErr error) error {
+ unlock, err := f.private.Lock(ctx, "floor.lock")
+ if err != nil {
+ return err
+ }
+ defer unlock()
+ accepted, aerr := f.record(rootFloorAccepted)
+ bound, berr := f.bound()
+ for _, fl := range legacy {
+ if validRootFloor(fl) {
+ bound = floorMaximum(bound, fl)
+ } else {
+ readErr = errors.Join(readErr, errRootFloorHistory)
+ }
+ }
+ if remote != nil && !validRootFloor(remote) {
+ readErr = errors.Join(readErr, errRootFloorHistory)
+ remote = nil
+ }
+ // Even uninitialized or corrupt histories retain every usable observed deny.
+ next := accepted
+ if aerr != nil {
+ next = rootFloorRecord{Schema: 1, Identity: f.identity, Generation: 1}
+ }
+ next.Generation++
+ refusal := errors.Join(readErr, berr, aerr)
+ if remote != nil {
+ if bound != nil {
+ refusal = errors.Join(refusal, floorRegression(bound, remote))
+ }
+ bound = floorMaximum(bound, remote)
+ }
+ if refusal == nil && remote == nil && string(rootFloorLine(bound)) != string(rootFloorLine(accepted.Floor)) {
+ refusal = errRootFloorHistory
+ }
+ if refusal == nil {
+ next.Floor = bound
+ next.NeverArmed = bound == nil
+ }
+ // A corrupt selector cannot be safely overwritten from incomplete evidence.
+ if berr != nil {
+ return f.quarantine(refusal)
+ }
+ if refusal == nil && !next.valid(f.identity) {
+ return f.quarantine(errRootFloorHistory)
+ }
+ err = f.persist(next, bound, refusal == nil)
+ return errors.Join(refusal, err)
+}
diff --git a/internal/host/rootmovesfloor_boot.go b/internal/host/rootmovesfloor_boot.go
new file mode 100644
index 000000000..8f1d479d3
--- /dev/null
+++ b/internal/host/rootmovesfloor_boot.go
@@ -0,0 +1,112 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+
+ "github.com/carlosframework/platform/internal/rootparticipation"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func rootFloorPrevVerdict(j rootFloorJudge) error {
+ p, err := j.floors.projection()
+ if err != nil {
+ return err
+ }
+ raw, err := j.floors.public.Read("prev.json")
+ if err != nil {
+ return err
+ }
+ var v rootFloorVerdict
+ if len(raw) > movesFloorMaxBytes || rootparticipation.Decode(raw, &v) != nil || v.Identity != p.Identity || v.Generation != p.Generation || !v.PrevOK || v.PrevAbsent {
+ return errRootFloorHistory
+ }
+ binary, err := j.binary(j.prev)
+ if err != nil {
+ return err
+ }
+ if binary != v.Prev || !binary.supports(p.Floor) {
+ return errRootFloorHistory
+ }
+ return nil
+}
+
+// Called under release, previous-binary and floor exclusion. A rejected prev
+// can only fall back to an independently verified current binary above the bound.
+func rootFloorBootCandidate(ctx context.Context, j rootFloorJudge, journal bool, reader func(context.Context, string) error, idle func(context.Context) error) (string, error) {
+ p, err := j.floors.projection()
+ if err != nil {
+ return "", err
+ }
+ if journal && rootFloorPrevVerdict(j) == nil && idle(ctx) == nil && reader(ctx, j.prev) == nil {
+ return j.prev, nil
+ }
+ current, err := j.binary(j.current)
+ if err != nil {
+ return "", err
+ }
+ if !current.supports(p.Floor) {
+ return "", errRootFloorHistory
+ }
+ return j.current, nil
+}
+
+// The additional argument deliberately fails in an older floor-allows-prev
+// reader. It is an internal unit protocol, not an operator reset interface.
+func rootFloorAllowsPrev(opt string) error {
+ if !filepath.IsAbs(opt) || filepath.Clean(opt) != opt {
+ return errFloorGateUsage
+ }
+ p, err := readRootFloorProjection(opt)
+ if err != nil {
+ return err
+ }
+ f, err := openRootMovesFloors(opt, p.Identity, false)
+ if err != nil {
+ return err
+ }
+ defer f.close()
+ return rootFloorPrevVerdict(rootFloorJudge{floors: f, prev: systemPrevPath(opt), binary: func(path string) (rootFloorBinary, error) { return readRootFloorBinary(opt, path) }})
+}
+
+func withRootFloorGuardCandidate(ctx context.Context, opt string, id rootFloorIdentity, journal bool, effect func(string) error) error {
+ return withClaimRootRelease(ctx, true, func() (rootstate.Store, error) { return rootstate.Open(rootReleasePrivate, true, false) }, func() error {
+ return withPrevLock(opt, func() error {
+ f, err := openRootMovesFloors(opt, id, false)
+ if err != nil {
+ return err
+ }
+ defer f.close()
+ unlock, err := f.private.Lock(ctx, "floor.lock")
+ if err != nil {
+ return err
+ }
+ defer unlock()
+ if _, err := readRootFloorProjection(opt); err != nil {
+ return err
+ }
+ 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)
+ return err
+ }, rootFloorDowngradeIdle)
+ if err != nil {
+ return err
+ }
+ return effect(candidate)
+ })
+ })
+}
+
+func rootFloorJournalPresent(opt string) (bool, error) {
+ f, err := rootstate.OpenAuthorityFile(filepath.Join(systemRootDir(opt), "journal.json"))
+ if errors.Is(err, os.ErrNotExist) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ return true, f.Close()
+}
diff --git a/internal/host/rootmovesfloor_boot_root_linux_test.go b/internal/host/rootmovesfloor_boot_root_linux_test.go
new file mode 100644
index 000000000..a91bcc72c
--- /dev/null
+++ b/internal/host/rootmovesfloor_boot_root_linux_test.go
@@ -0,0 +1,248 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func TestPlan14RootFloorBootSelectors(t *testing.T) {
+ dir := generationRootRunnerDir(t)
+ self, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ binary, err := os.ReadFile(self)
+ if err != nil {
+ t.Fatal(err)
+ }
+ runner := filepath.Join(dir, "runner.test")
+ if err := os.WriteFile(runner, binary, 0755); err != nil {
+ t.Fatal(err)
+ }
+ pid1, err := os.ReadFile("/proc/1/comm")
+ if err != nil || strings.TrimSpace(string(pid1)) != "systemd" {
+ t.Fatal("root runner requires systemd", err)
+ }
+ stage := filepath.Join(dir, "stage")
+ if err := bootstrap(stage, nil); err != nil {
+ 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"} {
+ t.Run(unit+"/"+attack, func(t *testing.T) {
+ opt, err := os.MkdirTemp(dir, "box-")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(opt, 0755); err != nil {
+ t.Fatal(err)
+ }
+ pub, key, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub))
+ id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"}
+ f, err := openRootMovesFloors(opt, id, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.close()
+ b := moves.Floor{Min: 1, MinRelease: "v2.0.0"}
+ if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &b, Source: "history", Digest: floorTestDigest, Authorization: "operator"}); err != nil {
+ t.Fatal(err)
+ }
+ mark := filepath.Join(opt, "executed")
+ makeBinary := func(path, version string, reader bool) {
+ body := "#!/bin/sh\nif [ \"$2\" = floor-allows-prev ]; then\n"
+ if reader {
+ body += " PLAN14_P3_GATE=1 exec " + runner + " -test.run=^TestPlan14RootFloorBootPayload$\n"
+ } else {
+ body += " exit 2\n"
+ }
+ body += "fi\nprintf '" + version + "' > " + mark + "\n"
+ rootFloorSignRunnerBytes(t, opt, path, version, []byte(body), key)
+ }
+ makeBinary(systemBinaryPath(opt), "v2.0.0", true)
+ makeBinary(systemPrevPath(opt), "v2.0.0", true)
+ j := rootFloorJudge{floors: f, current: systemBinaryPath(opt), prev: systemPrevPath(opt), binary: func(path string) (rootFloorBinary, error) { return readRootFloorBinary(opt, path) }, idle: func(context.Context) error { return nil }}
+ if err := j.judge(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ switch attack {
+ case "below":
+ makeBinary(systemBinaryPath(opt), "v1.0.0", true)
+ makeBinary(systemPrevPath(opt), "v1.0.0", true)
+ case "stale verdict":
+ if err := f.refresh(t.Context(), &b, nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ makeBinary(systemBinaryPath(opt), "v1.0.0", true)
+ case "missing accepted":
+ if err := f.private.Remove(rootFloorAccepted); err != nil {
+ t.Fatal(err)
+ }
+ case "missing floor":
+ if err := f.public.Remove(rootFloorSelector); err != nil {
+ t.Fatal(err)
+ }
+ case "corrupt floor":
+ if err := f.public.Write(rootFloorSelector, []byte("broken")); err != nil {
+ t.Fatal(err)
+ }
+ case "old reader":
+ makeBinary(systemPrevPath(opt), "v2.0.0", false)
+ if err := j.judge(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ makeBinary(systemBinaryPath(opt), "v1.0.0", true)
+ }
+ if strings.HasPrefix(attack, "crash-") {
+ fail := int(attack[len(attack)-1] - '0')
+ n := 0
+ broken := f
+ broken.private = &floorFaultStore{Store: f.private, n: &n, fail: fail, after: true}
+ broken.public = &floorFaultStore{Store: f.public, n: &n, fail: fail, after: true}
+ if err := broken.refresh(t.Context(), &moves.Floor{Min: 1, MinRelease: "v3.0.0"}, nil, nil); err == nil {
+ t.Fatal("crash injection succeeded")
+ }
+ }
+
+ guard := filepath.Join(opt, "system", "guard", "carlos")
+ if attack != "no guard" {
+ if err := os.WriteFile(guard, []byte("#!/bin/sh\nexec "+runner+" -test.run=^TestPlan14RootFloorBootPayload$\n"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.MkdirAll(filepath.Join(opt, "system", "router"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(opt, "system", "router", "journal.json"), []byte("{}"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := os.ReadFile(filepath.Join(stage, "etc", "systemd", "system", unit))
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := strings.ReplaceAll(string(raw), "/opt/carlos", opt)
+ body = strings.ReplaceAll(body, "/data", filepath.Join(opt, "data"))
+ // Preserve the service's hardening and actual selector. Remove only its
+ // deployment dependencies; all paths and identity are fixture-owned.
+ var lines []string
+ for _, line := range strings.Split(body, "\n") {
+ if strings.HasPrefix(line, "OnFailure=") || strings.HasPrefix(line, "Before=") || strings.HasPrefix(line, "After=") || strings.HasPrefix(line, "Wants=") || strings.HasPrefix(line, "EnvironmentFile=") {
+ continue
+ }
+ lines = append(lines, line)
+ if line == "[Service]" {
+ lines = append(lines, "Environment=PLAN14_P3_OPT="+opt, "Environment=CARLOS_RELEASE_PUBKEY="+base64.StdEncoding.EncodeToString(pub))
+ }
+ }
+ name := "plan14-p3-" + filepath.Base(opt) + ".service"
+ path := filepath.Join("/run/systemd/system", name)
+ if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ exec.Command("systemctl", "stop", name).Run()
+ os.Remove(path)
+ exec.Command("systemctl", "daemon-reload").Run()
+ exec.Command("systemctl", "reset-failed", name).Run()
+ })
+ if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
+ t.Fatal(string(out), err)
+ }
+ out, runErr := exec.CommandContext(t.Context(), "systemctl", "start", name).CombinedOutput()
+ got, readErr := os.ReadFile(mark)
+ if attack == "none" {
+ 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)
+ }
+ } else if runErr == nil || !os.IsNotExist(readErr) {
+ t.Fatalf("unsafe selector: %v %s marker=%s err=%v", runErr, out, got, readErr)
+ }
+ })
+ }
+ }
+}
+
+func TestPlan14RootFloorBootPayload(t *testing.T) {
+ opt := os.Getenv("PLAN14_P3_OPT")
+ if opt == "" {
+ t.Fatal("unit payload only")
+ }
+ if os.Getenv("PLAN14_P3_GATE") == "1" {
+ if err := rootFloorAllowsPrev(opt); err != nil {
+ t.Fatal(err)
+ }
+ return
+ }
+ id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"}
+ f, err := openRootMovesFloors(opt, id, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.close()
+ if _, err := readRootFloorProjection(opt); err != nil {
+ t.Fatal(err)
+ }
+ j := rootFloorJudge{floors: f, current: systemBinaryPath(opt), prev: systemPrevPath(opt), binary: func(path string) (rootFloorBinary, error) { return readRootFloorBinary(opt, path) }}
+ // Installation/participation is a fixture here; F4 exercises installed shell
+ // selection, the real signed-binary reader, floor records and prev reader.
+ path, err := rootFloorBootCandidate(t.Context(), j, true, func(ctx context.Context, path string) error {
+ cmd := exec.CommandContext(ctx, path, "system", "floor-allows-prev", "--root", opt)
+ cmd.Env = os.Environ()
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("%w: %s", err, out)
+ }
+ return nil
+ }, func(context.Context) error { return nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out, err := exec.CommandContext(t.Context(), path, "system", "apply").CombinedOutput(); err != nil {
+ t.Fatal(err, string(out))
+ }
+}
+
+func rootFloorSignRunnerBytes(t *testing.T, opt, path, version string, raw []byte, key ed25519.PrivateKey) {
+ t.Helper()
+ if err := os.WriteFile(path, raw, 0755); err != nil {
+ t.Fatal(err)
+ }
+ s, err := rootstate.Open(filepath.Join(opt, "system", "guard", "identities"), true, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ 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[:])}}}}
+ wire, err := release.SignRootEnvelope(release.RootSigner(key), p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Multiple fixture binaries may share a release tag, but never a digest.
+ if err := s.Write(hex.EncodeToString(sum[:])+".json", wire); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/host/rootmovesfloor_boot_test.go b/internal/host/rootmovesfloor_boot_test.go
new file mode 100644
index 000000000..39140d2a8
--- /dev/null
+++ b/internal/host/rootmovesfloor_boot_test.go
@@ -0,0 +1,83 @@
+package host
+
+import (
+ "context"
+ "os"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/moves"
+)
+
+func TestPlan14RootFloorBootDecision(t *testing.T) {
+ for _, attack := range []string{"none", "current below", "prev below", "stale verdict", "missing floor", "corrupt floor", "old reader", "pending participation", "replaced binary"} {
+ t.Run(attack, func(t *testing.T) {
+ f := rootMovesFloors{private: &rollbackMemory{files: map[string][]byte{}}, public: &rollbackMemory{files: map[string][]byte{}}, identity: rootFloorIdentity{Deployment: "d", Box: "b", Enrollment: "e"}}
+ b := moves.Floor{Min: 1, MinRelease: "v2.0.0"}
+ if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: f.identity, Floor: &b, Source: "history", Digest: floorTestDigest, Authorization: "root"}); err != nil {
+ t.Fatal(err)
+ }
+ current := rootFloorBinary{Version: "v2.0.0", Protocol: 1, Digest: floorTestDigest}
+ prev := current
+ j := rootFloorJudge{floors: f, current: "current", prev: "prev", binary: func(path string) (rootFloorBinary, error) {
+ if path == "prev" {
+ return prev, nil
+ }
+ return current, nil
+ }, idle: func(context.Context) error { return nil }}
+ if err := j.judge(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ switch attack {
+ case "current below":
+ current.Version = "v1.0.0"
+ prev = current
+ case "prev below":
+ prev.Version = "v1.0.0"
+ case "stale verdict":
+ if err := f.refresh(t.Context(), &b, nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ case "missing floor":
+ f.public.Remove(rootFloorSelector)
+ case "corrupt floor":
+ f.public.Write(rootFloorSelector, []byte("broken"))
+ case "replaced binary":
+ prev.Digest = "replacement"
+ }
+ calls := 0
+ gate := func(context.Context, string) error {
+ calls++
+ if attack == "old reader" {
+ return os.ErrPermission
+ }
+ return nil
+ }
+ idle := func(context.Context) error {
+ if attack == "pending participation" {
+ return os.ErrPermission
+ }
+ return nil
+ }
+ got, err := rootFloorBootCandidate(t.Context(), j, true, gate, idle)
+ if attack == "missing floor" || attack == "corrupt floor" || attack == "current below" {
+ if err == nil {
+ t.Fatal("unsafe executable selected", got)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := "current"
+ if attack == "none" {
+ want = "prev"
+ }
+ if got != want {
+ t.Fatal(got, want)
+ }
+ if attack == "prev below" && calls != 0 {
+ t.Fatal("below-floor previous binary executed for gate")
+ }
+ })
+ }
+}
diff --git a/internal/host/rootmovesfloor_identity.go b/internal/host/rootmovesfloor_identity.go
new file mode 100644
index 000000000..334caadf2
--- /dev/null
+++ b/internal/host/rootmovesfloor_identity.go
@@ -0,0 +1,36 @@
+package host
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "path/filepath"
+ "reflect"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+// Retain the independently verified pointer before its artifact can replace a
+// recovery binary. A pointer race retries; no existing identity is overwritten.
+func rememberRootFloorSystemIdentity(ctx context.Context, store release.Store, pub ed25519.PublicKey, opt string, expected *release.SystemPointer) error {
+ raw, _, err := store.Get(ctx, release.SystemPointerKey("router", expected.Channel))
+ if err != nil {
+ return err
+ }
+ verified, err := release.VerifySystemIdentity(pub, raw)
+ if err != nil {
+ return err
+ }
+ if verified.Component != "router" || verified.Channel != expected.Channel || !reflect.DeepEqual(verified.Current, expected.Current) {
+ return errRootFloorHistory
+ }
+ dir, err := rootstate.Open(filepath.Join(opt, "system", "guard", "identities"), true, true)
+ if err != nil {
+ return err
+ }
+ defer dir.Close()
+ digest := sha256.Sum256(raw)
+ return dir.Write(hex.EncodeToString(digest[:])+".json", raw)
+}
diff --git a/internal/host/rootmovesfloor_refresh.go b/internal/host/rootmovesfloor_refresh.go
new file mode 100644
index 000000000..19117950b
--- /dev/null
+++ b/internal/host/rootmovesfloor_refresh.go
@@ -0,0 +1,259 @@
+package host
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "runtime"
+ "time"
+
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rootparticipation"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+type rootFloorBinary struct {
+ Version, Digest string
+ Protocol int
+ File rootstate.FileIdentity
+}
+type rootFloorVerdict struct {
+ Identity rootFloorIdentity
+ Generation uint64
+ Current, Prev rootFloorBinary
+ CurrentOK, PrevAbsent, PrevOK bool
+}
+type rootFloorAdvertisement struct {
+ State, MinRelease, PrevRelease, PrevAllowedFor string
+ Generation uint64
+}
+type rootFloorJudge struct {
+ floors rootMovesFloors
+ remote func(context.Context) (*moves.Floor, error)
+ legacy func() ([]*moves.Floor, error)
+ binary func(string) (rootFloorBinary, error)
+ idle func(context.Context) error
+ current, prev string
+}
+
+func (b rootFloorBinary) supports(fl *moves.Floor) bool {
+ if b.Digest == "" || !validFloorRelease(b.Version) || b.Protocol < 1 {
+ return false
+ }
+ if fl == nil {
+ return true
+ }
+ at, err := moves.ReleaseAtOrAbove(b.Version, fl.MinRelease)
+ return err == nil && at && b.Protocol >= fl.Min
+}
+
+func (j rootFloorJudge) refresh(ctx context.Context) error {
+ remote, rerr := j.remote(ctx)
+ legacy, lerr := j.legacy()
+ if err := j.floors.refresh(ctx, remote, legacy, errors.Join(rerr, lerr)); err != nil {
+ return err
+ }
+ return j.judge(ctx)
+}
+func (j rootFloorJudge) judge(ctx context.Context) error {
+ f := j.floors
+ unlock, err := f.private.Lock(ctx, "floor.lock")
+ if err != nil {
+ return err
+ }
+ defer unlock()
+ p, err := f.projection()
+ if err != nil {
+ return err
+ }
+ // Revoke a same-generation verdict before any observation that can fail.
+ if err := f.public.Write("prev.json", []byte("{}")); err != nil {
+ return err
+ }
+ v := rootFloorVerdict{Identity: f.identity, Generation: p.Generation}
+ v.Current, err = j.binary(j.current)
+ if err != nil {
+ return err
+ }
+ v.CurrentOK = v.Current.supports(p.Floor)
+ v.Prev, err = j.binary(j.prev)
+ if errors.Is(err, os.ErrNotExist) {
+ v.PrevAbsent = true
+ } else if err != nil {
+ return err
+ } else if v.Prev.supports(p.Floor) {
+ if err := j.idle(ctx); err != nil {
+ return err
+ }
+ v.PrevOK = true
+ }
+ raw, _ := json.Marshal(v)
+ if err := f.public.Write("prev.json", raw); err != nil {
+ return errors.Join(err, f.public.Write("prev.json", []byte("{}")))
+ }
+ return nil
+}
+func (f rootMovesFloors) advertisement() (rootFloorAdvertisement, error) {
+ p, err := f.projection()
+ if err != nil {
+ return rootFloorAdvertisement{}, err
+ }
+ return rootFloorAdvertisementFor(f.public, p)
+}
+func rootFloorAdvertisementFor(public rootstate.Store, p rootFloorProjection) (rootFloorAdvertisement, error) {
+ ad := rootFloorAdvertisement{State: "quarantined", Generation: p.Generation}
+ raw, err := public.Read("prev.json")
+ if err != nil {
+ return ad, err
+ }
+ var v rootFloorVerdict
+ if len(raw) > movesFloorMaxBytes || rootparticipation.Decode(raw, &v) != nil || v.Identity != p.Identity || v.Generation != p.Generation || !v.CurrentOK || !v.Current.supports(p.Floor) {
+ return ad, errRootFloorHistory
+ }
+ ad.State = "accepted"
+ if p.Floor != nil {
+ ad.MinRelease = p.Floor.MinRelease
+ }
+ if v.PrevAbsent {
+ ad.PrevRelease = "none"
+ } else {
+ ad.PrevRelease = v.Prev.Version
+ if v.PrevOK && v.Prev.supports(p.Floor) && p.Floor != nil {
+ ad.PrevAllowedFor = p.Floor.MinRelease
+ }
+ }
+ return ad, nil
+}
+
+// A binary's signed identity, not its mutable version sidecar, supplies its
+// release and protocol. The cache is root-owned; each envelope is reverified.
+func readRootFloorBinary(opt, path string) (rootFloorBinary, error) {
+ var b rootFloorBinary
+ f, err := rootstate.OpenAuthorityFile(path)
+ if err != nil {
+ return b, err
+ }
+ defer f.Close()
+ b.File, err = rootstate.Identity(f)
+ if err != nil {
+ return b, err
+ }
+ st, err := f.Stat()
+ if err != nil {
+ return b, err
+ }
+ if st.Size() > 512<<20 || st.Mode()&0111 == 0 {
+ return b, os.ErrPermission
+ }
+ sum := sha256.New()
+ if _, err := io.Copy(sum, f); err != nil {
+ return b, err
+ }
+ b.Digest = hex.EncodeToString(sum.Sum(nil))
+ pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
+ if err != nil {
+ return b, err
+ }
+ identities, err := rootstate.Open(filepath.Join(opt, "system", "guard", "identities"), false, true)
+ if err != nil {
+ return b, err
+ }
+ defer identities.Close()
+ names, err := identities.Names()
+ if err != nil {
+ return b, err
+ }
+ for _, name := range names {
+ raw, err := identities.Read(name)
+ if err != nil {
+ return b, err
+ }
+ p, err := release.VerifySystemIdentity(pub, raw)
+ if err != nil {
+ return b, err
+ }
+ if p.Current.Artifacts[runtime.GOOS+"-"+runtime.GOARCH].SHA256 == b.Digest {
+ b.Version, b.Protocol = p.Current.ReleaseTag, p.Current.MovesProtocol
+ return b, nil
+ }
+ }
+ return b, errRootFloorHistory
+}
+
+func rootFloorDowngradeIdle(ctx context.Context) error {
+ l, err := rootparticipation.Open()
+ if err != nil {
+ return err
+ }
+ defer l.State.Close()
+ if err := l.Idle(ctx); err != nil {
+ return err
+ }
+ pins, err := productionRootGenerationPins().StagedPins(ctx, release.OptDir())
+ if err != nil {
+ return err
+ }
+ if len(pins) > 0 {
+ return errRootFloorHistory
+ }
+ return nil
+}
+
+func rootRefreshAndJudgeMovesPrev(id rootFloorIdentity) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ opt, dd := release.OptDir(), dataDir()
+ // The adopt pass already owns release exclusion. Acquire no release lock here.
+ return withPrevLock(opt, func() error {
+ f, err := openRootMovesFloors(opt, id, true)
+ if err != nil {
+ return err
+ }
+ defer f.close()
+ j := rootFloorJudge{floors: f, current: systemBinaryPath(opt), prev: systemPrevPath(opt), idle: rootFloorDowngradeIdle,
+ binary: func(path string) (rootFloorBinary, error) { return readRootFloorBinary(opt, path) },
+ legacy: func() ([]*moves.Floor, error) { return rootFloorLegacy(dd) },
+ remote: func(ctx context.Context) (*moves.Floor, error) {
+ s, err := openDeployStore(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return moves.ReadFloor(ctx, s)
+ }}
+ return j.refresh(ctx)
+ })
+}
+
+func withRootMovesFields(f edgesFile, opt string) edgesFile {
+ f.MovesProtocol = moves.ProtocolVersion
+ f.MovesFloorState = "quarantined"
+ f.MovesFloorMinRelease, f.PrevRelease, f.PrevAllowedFor = "", "", ""
+ p, err := readRootFloorProjection(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(rootFloorDir(opt), false, true)
+ if err != nil {
+ return f
+ }
+ defer public.Close()
+ ad, err := rootFloorAdvertisementFor(public, p)
+ if err != nil {
+ return f
+ }
+ if rootstate.Sync(public) != nil {
+ return f
+ }
+ f.MovesFloorState, f.MovesFloorGeneration = ad.State, ad.Generation
+ f.MovesFloorMinRelease, f.PrevRelease, f.PrevAllowedFor = ad.MinRelease, ad.PrevRelease, ad.PrevAllowedFor
+ return f
+}
diff --git a/internal/host/rootmovesfloor_refresh_test.go b/internal/host/rootmovesfloor_refresh_test.go
new file mode 100644
index 000000000..734a023ef
--- /dev/null
+++ b/internal/host/rootmovesfloor_refresh_test.go
@@ -0,0 +1,123 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestPlan14FloorRiseWithoutPlatformUpdate(t *testing.T) {
+ for _, prev := range []string{"none", "v2.0.0", "v1.0.0"} {
+ t.Run(prev, func(t *testing.T) {
+ id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"}
+ f := rootMovesFloors{private: &rollbackMemory{files: map[string][]byte{}}, public: &rollbackMemory{files: map[string][]byte{}}, identity: id}
+ a := moves.Floor{Min: 1, MinRelease: "v1.0.0"}
+ b := moves.Floor{Min: 2, MinRelease: "v2.0.0"}
+ if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &a, Source: "history", Digest: floorTestDigest, Authorization: "root"}); err != nil {
+ t.Fatal(err)
+ }
+ store := release.NewDirStore(t.TempDir())
+ raw, _ := json.Marshal(b)
+ if _, err := store.Put(t.Context(), moves.FloorKey, raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ mirror := t.TempDir()
+ if _, err := syncLocalFloor(t.Context(), store, mirror); err != nil {
+ t.Fatal(err)
+ }
+ if p, err := f.projection(); err != nil || p.Floor.MinRelease != a.MinRelease {
+ t.Fatal("mirror acknowledged B", p, err)
+ }
+ reads := 0
+ read := func(context.Context) (*moves.Floor, error) { reads++; return &b, nil }
+ binary := func(path string) (rootFloorBinary, error) {
+ if path == "prev" && prev == "none" {
+ return rootFloorBinary{}, os.ErrNotExist
+ }
+ v := "v2.0.0"
+ if path == "prev" {
+ v = prev
+ }
+ return rootFloorBinary{Version: v, Protocol: 2, Digest: floorTestDigest}, nil
+ }
+ judge := rootFloorJudge{floors: f, remote: read, legacy: func() ([]*moves.Floor, error) { return rootFloorLegacy(mirror) }, binary: binary, current: "current", prev: "prev", idle: func(context.Context) error { return nil }}
+ if err := judge.refresh(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ if reads != 1 {
+ t.Fatal("no-prev path skipped authoritative GET")
+ }
+ p, err := f.projection()
+ if err != nil || p.Floor == nil || *p.Floor != b {
+ t.Fatal("root refresh", p, err)
+ }
+ ad, err := f.advertisement()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ad.PrevRelease != prev {
+ t.Fatal("prev identity", ad)
+ }
+ if (ad.PrevAllowedFor == b.MinRelease) != (prev == "v2.0.0") {
+ t.Fatal("verdict", ad)
+ }
+ if err := os.RemoveAll(mirror); err != nil {
+ t.Fatal(err)
+ }
+ if again, err := f.advertisement(); err != nil || again != ad {
+ t.Fatal("mirror changed advertisement", again, err)
+ }
+ // A failed persistence cannot publish the new generation's acknowledgement.
+ n := 0
+ broken := f
+ broken.private = &floorFaultStore{Store: f.private, n: &n, fail: 1}
+ judge.floors = broken
+ if judge.refresh(t.Context()) == nil {
+ t.Fatal("failed floor sync accepted")
+ }
+ if _, err := f.advertisement(); err == nil {
+ t.Fatal("failed sync left acceptance")
+ }
+ })
+ }
+}
+
+func TestPlan14RootFloorRecoveryRefresh(t *testing.T) {
+ for _, entry := range []string{"recover", "rollback"} {
+ t.Run(entry, func(t *testing.T) {
+ f, _ := applyHappy(t)
+ j := f.brokenBox("v2.0.0", "v1.0.0", "v1.0.0")
+ journal := filepath.Join(systemRootDir(f.a.optDir), "journal.json")
+ before, _ := os.ReadFile(systemBinaryPath(f.a.optDir))
+ original, _ := os.ReadFile(journal)
+ unavailable := errors.New("provider unavailable")
+ reads := 0
+ f.a.rootMoves = func() (rootFloorIdentity, bool, error) {
+ return rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"}, true, nil
+ }
+ f.a.openStore = func(context.Context) (release.Store, ed25519.PublicKey, error) { reads++; return nil, nil, unavailable }
+ var err error
+ if entry == "recover" {
+ _, err = f.a.recoverIfNeeded(t.Context())
+ } else {
+ _, err = f.a.restoreFiles(t.Context(), &j)
+ }
+ if !errors.Is(err, unavailable) || reads != 1 {
+ t.Fatalf("remote floor not refreshed first: reads=%d err=%v", reads, err)
+ }
+ after, _ := os.ReadFile(systemBinaryPath(f.a.optDir))
+ retained, _ := os.ReadFile(journal)
+ if !bytes.Equal(before, after) || !bytes.Equal(original, retained) {
+ t.Fatal("failed refresh changed executable or journal")
+ }
+ })
+ }
+}
diff --git a/internal/host/rootmovesfloor_root_linux_test.go b/internal/host/rootmovesfloor_root_linux_test.go
new file mode 100644
index 000000000..79e4f82b1
--- /dev/null
+++ b/internal/host/rootmovesfloor_root_linux_test.go
@@ -0,0 +1,362 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rootparticipation"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "sync/atomic"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func TestPlan14RootFloorCrashOrderingRootRunner(t *testing.T) {
+ for _, after := range []bool{false, true} {
+ for fail := 1; fail <= 5; fail++ {
+ dir := generationRootRunnerDir(t)
+ id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"}
+ f, err := openRootMovesFloors(dir, id, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ a := moves.Floor{Min: 1, MinRelease: "v1.0.0"}
+ b := moves.Floor{Min: 2, MinRelease: "v2.0.0"}
+ if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &a, Source: "root observation", Digest: floorTestDigest, Authorization: "operator"}); err != nil {
+ t.Fatal(err)
+ }
+ n := 0
+ broken := f
+ broken.private = &floorFaultStore{Store: f.private, n: &n, fail: fail, after: after}
+ broken.public = &floorFaultStore{Store: f.public, n: &n, fail: fail, after: after}
+ if err := broken.refresh(t.Context(), &b, nil, nil); err == nil {
+ t.Fatal("injected write succeeded")
+ }
+ f.close()
+ f, err = openRootMovesFloors(dir, id, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p, e := readRootFloorProjection(dir); e == nil && p.Floor != nil && *p.Floor == b {
+ t.Fatal("failed write acknowledged B")
+ }
+ if err := f.refresh(t.Context(), &b, nil, nil); err != nil {
+ t.Fatal("retry", err)
+ }
+ if p, e := readRootFloorProjection(dir); e != nil || p.Floor == nil || *p.Floor != b {
+ t.Fatal("durable retry", p, e)
+ }
+ f.close()
+ // Root-owned leaf under a service-owned ancestor must not open.
+ if err := os.Chown(rootFloorDir(dir), 65534, 65534); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := rootstate.Open(filepath.Join(rootFloorDir(dir), "private"), false, false); err == nil {
+ t.Fatal("hostile parent accepted")
+ }
+ if err := os.Chown(rootFloorDir(dir), 0, 0); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+}
+
+func TestPlan14FloorRiseWithoutPlatformUpdateRootRunner(t *testing.T) {
+ for _, prev := range []string{"none", "v2.0.0", "v1.0.0"} {
+ t.Run(prev, func(t *testing.T) {
+ opt := generationRootRunnerDir(t)
+ dd := filepath.Join(opt, "data")
+ if err := os.Mkdir(dd, 0755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "deploy")
+ pub, key, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub))
+ rootFloorRunnerBinary(t, opt, systemBinaryPath(opt), "v2.0.0", key)
+ if prev != "none" {
+ rootFloorRunnerBinary(t, opt, systemPrevPath(opt), prev, key)
+ }
+ id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"}
+ f, err := openRootMovesFloors(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: "history", Digest: floorTestDigest, Authorization: "operator"}); err != nil {
+ t.Fatal(err)
+ }
+ store := release.NewDirStore(t.TempDir())
+ raw, _ := json.Marshal(b)
+ if _, err := store.Put(t.Context(), moves.FloorKey, raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := syncLocalFloor(t.Context(), store, dd); err != nil {
+ t.Fatal(err)
+ }
+ if ad := withMovesFields(edgesFile{Box: "box"}, dd, opt); ad.MovesFloorMinRelease == b.MinRelease {
+ t.Fatal("agent mirror acknowledged B")
+ }
+ gets := 0
+ j := rootFloorJudge{floors: f, current: systemBinaryPath(opt), prev: systemPrevPath(opt), idle: func(context.Context) error { return nil }, binary: func(path string) (rootFloorBinary, error) { return readRootFloorBinary(opt, path) }, legacy: func() ([]*moves.Floor, error) { return rootFloorLegacy(dd) }, remote: func(ctx context.Context) (*moves.Floor, error) { gets++; return moves.ReadFloor(ctx, store) }}
+ if err := j.refresh(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ ad := withMovesFields(edgesFile{Box: "box"}, dd, opt)
+ if gets != 1 || ad.MovesFloorState != "accepted" || ad.MovesFloorMinRelease != b.MinRelease || ad.PrevRelease != prev {
+ t.Fatal("root projection", gets, ad)
+ }
+ if (ad.PrevAllowedFor == b.MinRelease) != (prev == "v2.0.0") {
+ t.Fatal("prev verdict", ad)
+ }
+ if err := os.RemoveAll(filepath.Join(dd, ".moves")); err != nil {
+ t.Fatal(err)
+ }
+ again := withMovesFields(edgesFile{Box: "box"}, dd, opt)
+ if again.MovesFloorMinRelease != ad.MovesFloorMinRelease || again.PrevRelease != ad.PrevRelease {
+ t.Fatal("mirror deletion changed acceptance")
+ }
+ if err := os.WriteFile(systemBinaryPath(opt), []byte("forged"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if j.refresh(t.Context()) == nil {
+ t.Fatal("unsigned binary accepted")
+ }
+ if withMovesFields(edgesFile{Box: "box"}, dd, opt).MovesFloorState == "accepted" {
+ t.Fatal("unsigned current acknowledged")
+ }
+ })
+ }
+}
+
+func rootFloorRunnerBinary(t *testing.T, opt, path, version string, key ed25519.PrivateKey) {
+ t.Helper()
+ raw := []byte("#!/bin/sh\n# " + version + "\nexit 0\n")
+ if err := os.WriteFile(path, raw, 0755); err != nil {
+ t.Fatal(err)
+ }
+ identities, err := rootstate.Open(filepath.Join(opt, "system", "guard", "identities"), true, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer identities.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[:])}}}}
+ wire, err := release.SignRootEnvelope(release.RootSigner(key), p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := release.VerifySystemIdentity(key.Public().(ed25519.PublicKey), wire); err != nil {
+ t.Fatal(err)
+ }
+ if err := identities.Write(version+".json", wire); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestPlan14RootFloorAgentProjectionPayload(t *testing.T) {
+ opt := os.Getenv("PLAN14_P3_AGENT_OPT")
+ if opt == "" || os.Geteuid() == 0 {
+ t.Fatal("service-UID payload only")
+ }
+ ad := withMovesFields(edgesFile{Box: "box"}, filepath.Join(opt, "data"), opt)
+ if ad.MovesFloorState != "accepted" || ad.MovesFloorMinRelease != "v2.0.0" {
+ t.Fatal("service projection refused", ad)
+ }
+ // The same UID must be unable to change either projection or private state.
+ for _, path := range []string{filepath.Join(rootFloorDir(opt), rootFloorProjectionFile), filepath.Join(rootFloorDir(opt), "private", rootFloorAccepted)} {
+ if err := os.WriteFile(path, []byte("forged"), 0644); err == nil {
+ t.Fatal("service changed root floor")
+ }
+ }
+}
+
+func TestPlan14RootFloorAdoptRefreshRootRunner(t *testing.T) {
+ // This fixture owns its whole namespace; refuse any existing root census.
+ if err := os.Mkdir("/var/lib/carlos-claims", 0700); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := os.RemoveAll("/var/lib/carlos-claims"); err != nil {
+ t.Error(err)
+ }
+ })
+ census, err := rootstate.Open(rootparticipation.Namespace, true, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, _ := json.Marshal(rootparticipation.Census{Schema: 1, Box: "box", Enrollment: "en", Migration: 1, Revision: 1, LaunchRevision: 1, Complete: true})
+ if err := census.Write("census.json", raw); err != nil {
+ t.Fatal(err)
+ }
+ census.Close()
+ opt := generationRootRunnerDir(t)
+ dd := filepath.Join(opt, "data")
+ if err := os.Mkdir(dd, 0755); err != nil {
+ t.Fatal(err)
+ }
+ storeDir := filepath.Join(opt, "deployment")
+ if err := os.Mkdir(storeDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("CARLOS_OPT_DIR", opt)
+ t.Setenv("CARLOS_DATA_DIR", dd)
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "deploy")
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", storeDir)
+ pub, key, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub))
+ rootFloorRunnerBinary(t, opt, systemBinaryPath(opt), "v2.0.0", key)
+ id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"}
+ f, err := openRootMovesFloors(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: "history", Digest: floorTestDigest, Authorization: "root"}); err != nil {
+ t.Fatal(err)
+ }
+ store := release.NewDirStore(storeDir)
+ raw, _ = json.Marshal(b)
+ if _, err := store.Put(t.Context(), moves.FloorKey, raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := syncLocalFloor(t.Context(), store, dd); err != nil {
+ t.Fatal(err)
+ }
+ // The real provider reads only this loopback S3 fixture; it makes no AWS call.
+ var gets atomic.Int32
+ endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "GET" || r.URL.Path != "/deploy/"+moves.FloorKey {
+ http.NotFound(w, r)
+ return
+ }
+ gets.Add(1)
+ w.Header().Set("ETag", "fixture")
+ w.Write(raw)
+ }))
+ defer endpoint.Close()
+ t.Setenv("AWS_REGION", "eu-west-1")
+ t.Setenv("AWS_DEFAULT_REGION", "eu-west-1")
+ t.Setenv("AWS_ACCESS_KEY_ID", "fixture")
+ t.Setenv("AWS_SECRET_ACCESS_KEY", "fixture")
+ t.Setenv("AWS_SESSION_TOKEN", "")
+ t.Setenv("AWS_EC2_METADATA_DISABLED", "true")
+ t.Setenv("AWS_ENDPOINT_URL_S3", endpoint.URL)
+ t.Setenv("CARLOS_S3_ENDPOINT", endpoint.URL)
+ t.Setenv("AWS_PROFILE", "")
+ t.Setenv("AWS_DEFAULT_PROFILE", "")
+ if err := withClaimRootRelease(t.Context(), true, func() (rootstate.Store, error) { return rootstate.Open(filepath.Join(opt, "release"), true, false) }, func() error { return rootJudgePrev(nil) }); err != nil {
+ t.Fatal(err)
+ }
+ if gets.Load() == 0 {
+ t.Fatal("no authoritative GET before no-prev return")
+ }
+ ad := withMovesFields(edgesFile{Box: "box"}, dd, opt)
+ if ad.MovesFloorState != "accepted" || ad.MovesFloorMinRelease != b.MinRelease || ad.PrevRelease != "none" {
+ t.Fatal("actual adopt refresh", ad)
+ }
+ rawBinary, err := os.ReadFile(os.Args[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ agentBinary := filepath.Join(opt, "agent.test")
+ if err := os.WriteFile(agentBinary, rawBinary, 0755); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.CommandContext(t.Context(), agentBinary, "-test.run=^TestPlan14RootFloorAgentProjectionPayload$", "-test.v")
+ cmd.Dir = opt
+ cmd.Env = append(os.Environ(), "PLAN14_P3_AGENT_OPT="+opt)
+ cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: 65534, Gid: 65534}}
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("service UID: %v\n%s", err, out)
+ }
+ // Direct apply/recovery adapters cannot use erased legacy files to go back.
+ if err := os.RemoveAll(filepath.Join(dd, ".moves")); err != nil {
+ t.Fatal(err)
+ }
+ app := systemApplier{optDir: opt, dataDir: dd, rootMoves: rootMovesFloorIdentity}
+ if app.floorAllowsForward("v1.0.0") == nil {
+ t.Fatal("legacy deletion allowed downgrade")
+ }
+ if err := app.floorAllowsForward("v2.0.0"); err != nil {
+ t.Fatal("same floor refused", err)
+ }
+}
+
+func TestPlan14RootFloorSignedTargetRootRunner(t *testing.T) {
+ opt := generationRootRunnerDir(t)
+ pub, key, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub))
+ id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"}
+ f, err := openRootMovesFloors(opt, id, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.close()
+ b := moves.Floor{Min: 1, MinRelease: "v2.0.0"}
+ if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &b, Source: "history", Digest: floorTestDigest, Authorization: "operator"}); err != nil {
+ t.Fatal(err)
+ }
+ store := release.NewDirStore(t.TempDir())
+ platform := runtime.GOOS + "-" + runtime.GOARCH
+ bytes := []byte("new signed target without earlier root history")
+ sum := sha256.Sum256(bytes)
+ p, err := release.PromoteSystem(t.Context(), store, release.RootSigner(key), "router", "stable", release.SystemRelease{Version: "v3.0.0", ReleaseTag: "v3.0.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{platform: {Key: release.SystemArtifactKey("v3.0.0", platform), SHA256: hex.EncodeToString(sum[:])}}}, release.SystemPromoteOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ staged := filepath.Join(opt, "staging", "carlos")
+ if err := os.Mkdir(filepath.Dir(staged), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(staged, bytes, 0755); err != nil {
+ t.Fatal(err)
+ }
+ calls := 0
+ a := systemApplier{optDir: opt, channel: "stable", rootMoves: func() (rootFloorIdentity, bool, error) { return id, true, nil }, openStore: func(context.Context) (release.Store, ed25519.PublicKey, error) { return store, pub, nil }, ops: sysOps{run: func(context.Context, time.Duration, string, ...string) (string, error) { calls++; return "ran", nil }}}
+ if _, err := a.verifyPointer(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := a.guardRun(t.Context(), time.Second, staged, "version"); err != nil || calls != 1 {
+ t.Fatal("signed target with unknown history refused", err, calls)
+ }
+ changed := *p
+ changed.Current.Version = "other"
+ if rememberRootFloorSystemIdentity(t.Context(), store, pub, opt, &changed) == nil {
+ t.Fatal("pointer substitution accepted")
+ }
+ if err := os.WriteFile(staged, []byte("substitution"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := a.guardRun(t.Context(), time.Second, staged, "version"); err == nil || calls != 1 {
+ t.Fatal("unsigned target executed", err, calls)
+ }
+}
diff --git a/internal/host/rootmovesfloor_source.go b/internal/host/rootmovesfloor_source.go
new file mode 100644
index 000000000..d862bd74c
--- /dev/null
+++ b/internal/host/rootmovesfloor_source.go
@@ -0,0 +1,202 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rootparticipation"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func rootMovesFloorIdentity() (rootFloorIdentity, bool, error) {
+ l, required, err := rootparticipation.OpenForUpdate()
+ if l.State != nil {
+ defer l.State.Close()
+ }
+ if err != nil || !required {
+ return rootFloorIdentity{}, required, err
+ }
+ c, err := l.Census()
+ id := rootFloorIdentity{Deployment: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), Box: c.Box, Enrollment: c.Enrollment}
+ if err == nil && id.Deployment == "" {
+ err = errRootFloorHistory
+ }
+ return id, true, err
+}
+
+type rootFloorIdentitySource func() (rootFloorIdentity, bool, error)
+
+func (source rootFloorIdentitySource) read() (rootFloorIdentity, bool, error) {
+ if source == nil {
+ return rootFloorIdentity{}, false, nil
+ }
+ return source()
+}
+
+// Root callers retain main's floor implementation until a census exists.
+func readEffectiveMovesFloor(source rootFloorIdentitySource, opt, dd string) (*moves.Floor, error) {
+ id, required, err := source.read()
+ if err != nil {
+ return nil, err
+ }
+ if !required {
+ return readLocalFloor(dd)
+ }
+ f, err := openRootMovesFloors(opt, id, false)
+ if err != nil {
+ return nil, err
+ }
+ defer f.close()
+ bound, err := f.bound()
+ if err != nil {
+ return bound, err
+ }
+ if _, err = f.projection(); err != nil {
+ return bound, err
+ }
+ return bound, nil
+}
+
+func withEffectiveMovesFloorLock(source rootFloorIdentitySource, opt, dd string, create bool, fn func() error) error {
+ id, required, err := source.read()
+ if err != nil {
+ return err
+ }
+ if !required {
+ return withFloorLock(dd, create, func(*movesDir) error { return fn() })
+ }
+ f, err := openRootMovesFloors(opt, id, false)
+ if err != nil {
+ return err
+ }
+ defer f.close()
+ unlock, err := f.private.Lock(context.Background(), "floor.lock")
+ if err != nil {
+ return err
+ }
+ defer unlock()
+ 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) {
+ var legacy []*moves.Floor
+ fl, jerr := readRootLegacyFloorJSON(dd)
+ if fl != nil {
+ if validRootFloor(fl) {
+ legacy = append(legacy, fl)
+ } else {
+ jerr = errors.Join(jerr, errRootFloorHistory)
+ }
+ }
+ file, err := openBeneath(dd, strings.Split(filepath.ToSlash(moves.FloorMinReleaseFile), "/")...)
+ if errors.Is(err, os.ErrNotExist) {
+ return legacy, jerr
+ }
+ if err != nil {
+ return legacy, errors.Join(jerr, err)
+ }
+ defer file.Close()
+ st, err := file.Stat()
+ if err != nil {
+ return legacy, errors.Join(jerr, err)
+ }
+ if !st.Mode().IsRegular() || st.Size() > 256 {
+ return legacy, errors.Join(jerr, errRootFloorHistory)
+ }
+ raw, err := io.ReadAll(io.LimitReader(file, 257))
+ line := strings.TrimSpace(string(raw))
+ if err != nil || !validFloorRelease(line) {
+ return legacy, errors.Join(jerr, err, errRootFloorHistory)
+ }
+ legacy = append(legacy, &moves.Floor{Min: 1, MinRelease: line})
+ return legacy, jerr
+}
+
+func refreshRootMovesFloor(ctx context.Context, store release.Store, opt, dd string, id rootFloorIdentity) error {
+ f, err := openRootMovesFloors(opt, id, true)
+ if err != nil {
+ return err
+ }
+ defer f.close()
+ remote, rerr := moves.ReadFloor(ctx, store)
+ legacy, lerr := rootFloorLegacy(dd)
+ return f.refresh(ctx, remote, legacy, errors.Join(rerr, lerr))
+}
+
+// Reading the final projection can race the writer's last directory sync.
+// 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)
+ if err != nil {
+ return rootFloorProjection{}, err
+ }
+ defer s.Close()
+ var p rootFloorProjection
+ raw, err := s.Read(rootFloorProjectionFile)
+ if err != nil {
+ return p, err
+ }
+ if err = rootstate.Sync(s); err != nil {
+ return p, err
+ }
+ if len(raw) > movesFloorMaxBytes || rootparticipation.Decode(raw, &p) != nil || !p.Accepted || !p.rootFloorRecord.valid(p.Identity) {
+ return p, errRootFloorHistory
+ }
+ f := rootMovesFloors{public: s, identity: p.Identity}
+ bound, err := f.selector()
+ if err != nil || string(rootFloorLine(bound)) != string(rootFloorLine(p.Floor)) {
+ return p, errRootFloorHistory
+ }
+ return p, nil
+}
+
+func readRootLegacyFloorJSON(dd string) (*moves.Floor, error) {
+ file, err := openBeneath(dd, strings.Split(filepath.ToSlash(moves.FloorFile), "/")...)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ st, err := file.Stat()
+ if err != nil {
+ return nil, err
+ }
+ if !st.Mode().IsRegular() || st.Size() > movesFloorMaxBytes {
+ return nil, errRootFloorHistory
+ }
+ raw, err := io.ReadAll(io.LimitReader(file, movesFloorMaxBytes+1))
+ if err != nil {
+ return nil, err
+ }
+ var floor moves.Floor
+ err = rootparticipation.Decode(raw, &floor)
+ if !validRootFloor(&floor) {
+ return nil, errors.Join(err, errRootFloorHistory)
+ }
+ return &floor, err
+}
+
+// Recovery can run before verifyPointer and armFloor. Fetch its own floor
+// before using a retained executable; a failed read leaves the journal pending.
+func (a *systemApplier) refreshRootFloorForRecovery(ctx context.Context) error {
+ id, required, err := a.rootMoves.read()
+ if err != nil || !required {
+ return err
+ }
+ store, _, err := a.openStore(ctx)
+ if err != nil {
+ return err
+ }
+ return refreshRootMovesFloor(ctx, store, a.optDir, a.dataDir, id)
+}
diff --git a/internal/host/rootmovesfloor_test.go b/internal/host/rootmovesfloor_test.go
new file mode 100644
index 000000000..36a71dd12
--- /dev/null
+++ b/internal/host/rootmovesfloor_test.go
@@ -0,0 +1,184 @@
+package host
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+// The disk implementation and privileged suite repeat this matrix using checked
+// rootstate descriptors. This suite keeps every write boundary runnable in CI.
+func TestPlan14RootFloorCrashOrdering(t *testing.T) {
+ for _, after := range []bool{false, true} {
+ for fail := 1; fail <= 5; fail++ {
+ private := &rollbackMemory{files: map[string][]byte{}}
+ public := &rollbackMemory{files: map[string][]byte{}}
+ f := rootMovesFloors{private: private, public: public, identity: rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"}}
+ a := moves.Floor{Min: 1, MinRelease: "v1.0.0"}
+ b := moves.Floor{Min: 2, MinRelease: "v2.0.0"}
+ if err := f.reconstruct(context.Background(), rootFloorReconstruction{Identity: f.identity, Floor: &a, Source: "complete root history", Digest: floorTestDigest, Authorization: "operator"}); err != nil {
+ t.Fatal(err)
+ }
+ n := 0
+ wrap := func(s rootstate.Store) rootstate.Store {
+ return &floorFaultStore{Store: s, n: &n, fail: fail, after: after}
+ }
+ broken := f
+ broken.private, broken.public = wrap(private), wrap(public)
+ if err := broken.refresh(context.Background(), &b, nil, nil); err == nil {
+ t.Fatalf("boundary %d after=%v succeeded", fail, after)
+ }
+ // No failed transaction may advertise its new accepted generation.
+ if p, err := f.projection(); err == nil && p.Floor != nil && *p.Floor == b {
+ t.Fatalf("boundary %d after=%v acknowledged B", fail, after)
+ }
+ // A visible stricter selector must not be overwritten by a weaker retry.
+ bound, _ := f.bound()
+ if bound != nil && *bound == b {
+ if err := f.refresh(context.Background(), &a, nil, nil); err == nil {
+ t.Fatal("regression accepted after interrupted rise")
+ }
+ }
+ if err := f.refresh(context.Background(), &b, nil, nil); err != nil {
+ t.Fatalf("retry %d/%v: %v", fail, after, err)
+ }
+ p, err := f.projection()
+ if err != nil || p.Floor == nil || *p.Floor != b {
+ t.Fatal("retry did not durably accept B", p, err)
+ }
+ }
+ }
+}
+
+const floorTestDigest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+
+type floorFaultStore struct {
+ rootstate.Store
+ n *int
+ fail int
+ after bool
+}
+
+func (s *floorFaultStore) Write(name string, raw []byte) error {
+ *s.n++
+ if *s.n == s.fail && !s.after {
+ return os.ErrInvalid
+ }
+ err := s.Store.Write(name, raw)
+ if *s.n == s.fail && s.after {
+ return os.ErrInvalid
+ }
+ return err
+}
+
+func TestPlan14RootFloorReconstructionAuthority(t *testing.T) {
+ a := moves.Floor{Min: 1, MinRelease: "v1.0.0"}
+ b := moves.Floor{Min: 2, MinRelease: "v2.0.0"}
+ for _, tc := range []string{"unknown", "legacy", "corrupt", "deleted", "regressed", "stronger legacy", "never armed", "wrong identity"} {
+ t.Run(tc, func(t *testing.T) {
+ f := rootMovesFloors{private: &rollbackMemory{files: map[string][]byte{}}, public: &rollbackMemory{files: map[string][]byte{}}, identity: rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"}}
+ if tc == "unknown" || tc == "legacy" {
+ var legacy []*moves.Floor
+ if tc == "legacy" {
+ legacy = []*moves.Floor{&b}
+ }
+ if f.refresh(t.Context(), &a, legacy, nil) == nil {
+ t.Fatal("unknown history accepted")
+ }
+ if _, err := f.projection(); err == nil {
+ t.Fatal("unknown history advertised")
+ }
+ if tc == "legacy" {
+ got, _ := f.bound()
+ if got == nil || *got != b {
+ t.Fatal("legacy deny lost", got)
+ }
+ }
+ return
+ }
+ r := rootFloorReconstruction{Identity: f.identity, Floor: &b, Source: "complete history", Digest: floorTestDigest, Authorization: "operator"}
+ if tc == "never armed" {
+ r.Floor = nil
+ r.NeverArmed = true
+ }
+ if tc == "wrong identity" {
+ r.Identity.Enrollment = "other"
+ if f.reconstruct(t.Context(), r) == nil {
+ t.Fatal("foreign reconstruction")
+ }
+ return
+ }
+ if err := f.reconstruct(t.Context(), r); err != nil {
+ t.Fatal(err)
+ }
+ var remote *moves.Floor
+ var legacy []*moves.Floor
+ var legacyErr error
+ switch tc {
+ case "corrupt":
+ remote = &a
+ legacyErr = os.ErrInvalid
+ case "regressed":
+ remote = &a
+ case "stronger legacy":
+ legacy = []*moves.Floor{{Min: 3, MinRelease: "v1.0.0"}}
+ remote = &b
+ case "never armed":
+ remote = &a
+ }
+ err := f.refresh(t.Context(), remote, legacy, legacyErr)
+ bad := tc == "corrupt" || tc == "regressed" || tc == "stronger legacy"
+ if (err != nil) != bad {
+ t.Fatal(err)
+ }
+ if bad {
+ if _, err := f.projection(); err == nil {
+ t.Fatal("quarantine advertised")
+ }
+ }
+ if tc == "deleted" {
+ got, e := f.bound()
+ if e != nil || got == nil || *got != b {
+ t.Fatal(got, e)
+ }
+ }
+ if tc == "never armed" {
+ p, e := f.projection()
+ if e != nil || p.Floor == nil || *p.Floor != a {
+ t.Fatal(p, e)
+ }
+ }
+ })
+ }
+}
+
+func TestPlan14RootFloorLegacyIsDenyOnly(t *testing.T) {
+ for _, body := range []string{`{"min":2,"min_release":"v2.0.0"} {}`, `{"min":2,"min_release":"v2.0.0","reset":true}`, `broken`} {
+ dd := t.TempDir()
+ dir := filepath.Join(dd, ".moves")
+ if err := os.Mkdir(dir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "floor.json"), []byte(body), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "floor.min-release"), []byte("v3.0.0\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ legacy, err := rootFloorLegacy(dd)
+ if err == nil {
+ t.Fatal("malformed mirror was not quarantined", body)
+ }
+ max := (*moves.Floor)(nil)
+ for _, fl := range legacy {
+ max = floorMaximum(max, fl)
+ }
+ if max == nil || max.MinRelease != "v3.0.0" {
+ t.Fatal("usable deny disappeared", max)
+ }
+ }
+}
diff --git a/internal/host/seedsfloor_test.go b/internal/host/seedsfloor_test.go
index fc92d7e2c..26289b0ef 100644
--- a/internal/host/seedsfloor_test.go
+++ b/internal/host/seedsfloor_test.go
@@ -63,7 +63,7 @@ func TestSeedsFloorIsDormantWhenNeverPublished(t *testing.T) {
if err := judgeSeedsPrev(opt, dd); err != nil {
t.Fatal(err)
}
- if err := rootJudgePrev(nil); err != nil {
+ if err := rootJudgePrevWithSource(nil, nil); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dd, ".moves")); !errors.Is(err, os.ErrNotExist) {
@@ -125,7 +125,7 @@ func TestRootJudgePrevJudgesTheSeedsFloorWithoutAMovesFloor(t *testing.T) {
t.Fatal(err)
}
armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.27.0"})
- if err := rootJudgePrev(nil); err != nil {
+ if err := rootJudgePrevWithSource(nil, nil); err != nil {
t.Fatal(err)
}
if line, ok := readLine(seedsPrevAllowedPath(opt)); !ok || line != "v0.27.0" {
@@ -133,7 +133,7 @@ func TestRootJudgePrevJudgesTheSeedsFloorWithoutAMovesFloor(t *testing.T) {
}
// The floor rises past carlos.prev: the next pass takes the verdict away.
armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.28.0"})
- if err := rootJudgePrev(nil); err != nil {
+ if err := rootJudgePrevWithSource(nil, nil); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) {
@@ -143,7 +143,7 @@ func TestRootJudgePrevJudgesTheSeedsFloorWithoutAMovesFloor(t *testing.T) {
writeFileDurable(seedsPrevAllowedPath(opt), []byte("v0.27.0\n"), 0o644)
os.Remove(filepath.Join(dd, seeds.FloorFile))
os.Remove(filepath.Join(dd, seeds.FloorMinReleaseFile))
- if err := rootJudgePrev(nil); err != nil {
+ if err := rootJudgePrevWithSource(nil, nil); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) {
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go
index ad80a4729..c145f925d 100644
--- a/internal/host/systemapply.go
+++ b/internal/host/systemapply.go
@@ -30,6 +30,7 @@ import (
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/rootparticipation"
+ "github.com/carlosframework/platform/internal/rootstate"
"github.com/carlosframework/platform/internal/version"
)
@@ -114,14 +115,22 @@ func RunSystemApply(args []string) {
log.Printf("system apply: %s=%q is not a channel; this box follows %s", systemChannelEnv, invalidChannel, channel)
}
a := &systemApplier{
- ops: productionSysOps(), openStore: openSystemStore, channel: channel,
+ rootMoves: rootMovesFloorIdentity, ops: productionSysOps(), openStore: openSystemStore, channel: channel,
optDir: release.OptDir(), dataDir: dataDir(), etcDir: "/etc", build: version.Build,
platform: runtime.GOOS + "-" + runtime.GOARCH, gateWait: systemGateWait, soak: systemGateSoak,
regionHA: regionHAEnabled(os.Getenv),
}
a.floorHook = installFloorHook // decided under the apply lock, in Run
- if err := a.Run(ctx, *recover); err != nil {
- log.Fatalf("%v", err)
+ var runErr error
+ if _, required, err := a.rootMoves.read(); err != nil {
+ runErr = err
+ } else if required {
+ runErr = withClaimRootRelease(ctx, true, func() (rootstate.Store, error) { return rootstate.Open(rootReleasePrivate, true, false) }, func() error { return a.Run(ctx, *recover) })
+ } else {
+ runErr = a.Run(ctx, *recover)
+ }
+ if runErr != nil {
+ log.Fatalf("%v", runErr)
}
}
@@ -208,6 +217,7 @@ func productionSysOps() sysOps {
}
type systemApplier struct {
+ rootMoves rootFloorIdentitySource
participation *rootparticipation.Ledger
floor *hostfloor.Disk
// floorHook, when set, decides legacy or floor-guarded state once Run
@@ -786,6 +796,17 @@ func (a *systemApplier) notePrev(ctx context.Context, replace bool, version stri
}
}
}
+ if id, required, err := a.rootMoves.read(); err != nil {
+ return err
+ } else if required {
+ f, err := openRootMovesFloors(a.optDir, id, false)
+ if err != nil {
+ return err
+ }
+ 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 a.floorStep(errors.Join(judgePrevWith(a.optDir, a.dataDir, a.freezeHolds, func() string { return a.prevBuild(ctx) }), judgeSeedsPrev(a.optDir, a.dataDir)))
}
err := withPrevLock(a.optDir, steps)
@@ -808,7 +829,7 @@ func (a *systemApplier) notePrev(ctx context.Context, replace bool, version stri
// (armed, value unknown). An armed seeds floor (seedsfloor.go) counts too,
// since the floor's steps write and judge both.
func (a *systemApplier) floorArmed() bool {
- f, err := readLocalFloor(a.dataDir)
+ f, err := readEffectiveMovesFloor(a.rootMoves, a.optDir, a.dataDir)
return err != nil || f != nil || seedsFloorArmed(a.dataDir)
}
@@ -834,6 +855,15 @@ func (a *systemApplier) floorStep(err error) error {
// deployment that never published a floor updates exactly as main does.
// The seeds floor is armed first, by the same rule (armSeedsFloor).
func (a *systemApplier) armFloor(ctx context.Context) error {
+ if id, required, err := a.rootMoves.read(); err != nil {
+ return err
+ } else if required {
+ if err := refreshRootMovesFloor(ctx, a.store, a.optDir, a.dataDir, id); err != nil {
+ return err
+ }
+ return a.armSeedsFloor(ctx)
+ }
+
if err := a.armSeedsFloor(ctx); err != nil {
return err
}
@@ -948,12 +978,12 @@ func (a *systemApplier) activationGuard(target, running string, fn func() error)
return fn()
}
locked := false
- err := withFloorLock(a.dataDir, true, func(*movesDir) error {
+ err := withEffectiveMovesFloorLock(a.rootMoves, a.optDir, a.dataDir, true, func() error {
locked = true
if err := seedsActivationRefusal(target, a.dataDir); err != nil {
return err
}
- floor, err := readLocalFloor(a.dataDir)
+ floor, err := readEffectiveMovesFloor(a.rootMoves, a.optDir, a.dataDir)
if err != nil {
return refuse("moves protocol floor: the floor on this box cannot be read: %v", err)
}
@@ -963,6 +993,16 @@ func (a *systemApplier) activationGuard(target, running string, fn func() error)
if err := floorRefusal(target, floor); err != nil {
return err
}
+ if _, required, err := a.rootMoves.read(); err != nil {
+ return err
+ } else if required {
+ if at, _ := moves.ReleaseAtOrAbove(target, running); !at {
+ if err := rootFloorDowngradeIdle(context.Background()); err != nil {
+ return err
+ }
+ }
+ }
+
frozen := false
err = a.freezeHolds(func(held []registry.Route) error {
frozen = true
@@ -1024,7 +1064,7 @@ func (a *systemApplier) activationRule(version string, running func() string) er
if err := seedsActivationRefusal(version, a.dataDir); err != nil {
return err
}
- floor, err := readLocalFloor(a.dataDir)
+ floor, err := readEffectiveMovesFloor(a.rootMoves, a.optDir, a.dataDir)
if err != nil {
return refuse("moves protocol floor: the floor on this box cannot be read: %v", err)
}
@@ -1058,7 +1098,7 @@ func (a *systemApplier) floorAllowsForward(version string) error {
if err := seedsActivationRefusal(version, a.dataDir); err != nil {
return err
}
- floor, err := readLocalFloor(a.dataDir)
+ floor, err := readEffectiveMovesFloor(a.rootMoves, a.optDir, a.dataDir)
if err != nil {
return fmt.Errorf("moves protocol floor: the floor on this box cannot be read: %v", err)
}
@@ -1138,6 +1178,23 @@ const systemApplyLayoutRefusal = "%s predates the instance layout, and this box
// way of a release before the layout. A registry that will not open cannot
// vouch that nothing is nested, so its error is itself a reason.
func (a *systemApplier) layoutReversalReasons() []string {
+ if _, required, err := a.rootMoves.read(); err != nil {
+ return []string{err.Error()}
+ } else if required {
+ if err := rootFloorDowngradeIdle(context.Background()); err != nil {
+ return []string{err.Error()}
+ }
+ snapshot, err := productionGenerationInventory(context.Background())
+ if err != nil {
+ return []string{err.Error()}
+ }
+ for _, view := range snapshot.Installations {
+ if !view.Installation.Empty {
+ return []string{errRootFloorHistory.Error()}
+ }
+ }
+ }
+
open := a.openRegistry
if open == nil {
open = func() (*registry.Registry, error) { return openSystemRegistry(a.dataDir) }
@@ -1602,6 +1659,9 @@ func (a *systemApplier) rollback(ctx context.Context, req systemRequest, reason
// it comes up at the next boot. No restart, no start: what to do about the
// running edge is the caller's, and at boot the answer is nothing.
func (a *systemApplier) restoreFiles(ctx context.Context, j *systemJournal) (bootstrapPlan, error) {
+ if err := a.refreshRootFloorForRecovery(ctx); err != nil {
+ return bootstrapPlan{}, err
+ }
prev := systemPrevPath(a.optDir)
if err := a.guardImage(prev); err != nil {
return bootstrapPlan{}, err
@@ -1740,6 +1800,9 @@ func (a *systemApplier) recoverIfNeeded(ctx context.Context) (bool, error) {
if err != nil || j == nil {
return false, err
}
+ if err := a.refreshRootFloorForRecovery(ctx); err != nil {
+ return false, err
+ }
// The result belongs to the promote being recovered, which the journal
// names in full. The request is used only when it is that same promote,
// version and promoted_at both: a re-promote of the same version is a
@@ -1909,6 +1972,13 @@ func (a *systemApplier) verifyPointer(ctx context.Context) (*release.SystemPoint
p, _, err := release.GetSystemPointer(ctx, store, pub, release.SystemComponentRouter, a.channel)
switch {
case err == nil:
+ if _, required, e := a.rootMoves.read(); e != nil {
+ return nil, e
+ } else if required {
+ if e := rememberRootFloorSystemIdentity(ctx, store, pub, a.optDir, p); e != nil {
+ return nil, e
+ }
+ }
return p, nil
case errors.Is(err, os.ErrNotExist):
return nil, fmt.Errorf("%w: no %s pointer for this box's channel", errSystemRefused, a.channel)
diff --git a/internal/host/units/carlos-system-recover.service b/internal/host/units/carlos-system-recover.service
index 9f2ceba80..84b856754 100644
--- a/internal/host/units/carlos-system-recover.service
+++ b/internal/host/units/carlos-system-recover.service
@@ -38,7 +38,7 @@ DefaultDependencies=yes
Type=oneshot
TimeoutStartSec=10min
EnvironmentFile=-/etc/carlos/host.env
-ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && [ ! -L /data/.moves/floor.min-release ] && { [ ! -e /data/.moves/floor.min-release ] || [ -f /data/.moves/floor.min-release ]; } && { ! [ -s /data/.moves/floor.min-release ] || /opt/carlos/carlos.prev system floor-allows-prev /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; } && [ ! -L /data/.moves/seeds-floor.min-release ] && { [ ! -e /data/.moves/seeds-floor.min-release ] || [ -f /data/.moves/seeds-floor.min-release ]; } && { ! [ -s /data/.moves/seeds-floor.min-release ] || /opt/carlos/carlos.prev system floor-allows-prev /data/.moves/seeds-floor.min-release /opt/carlos/carlos.prev.seeds-allowed; }; then exec /opt/carlos/carlos.prev system apply --recover; fi; exec /opt/carlos/carlos system apply --recover'
+ExecStart=/bin/sh -c 'if [ -e /var/lib/carlos-claims/authority/exec/census.json ] || [ -L /var/lib/carlos-claims/authority/exec/census.json ] || [ -e /opt/carlos/system/guard/moves ] || [ -L /opt/carlos/system/guard/moves ]; then exec /opt/carlos/system/guard/carlos system guard-exec recover; fi; if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && [ ! -L /data/.moves/floor.min-release ] && { [ ! -e /data/.moves/floor.min-release ] || [ -f /data/.moves/floor.min-release ]; } && { ! [ -s /data/.moves/floor.min-release ] || /opt/carlos/carlos.prev system floor-allows-prev /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; } && [ ! -L /data/.moves/seeds-floor.min-release ] && { [ ! -e /data/.moves/seeds-floor.min-release ] || [ -f /data/.moves/seeds-floor.min-release ]; } && { ! [ -s /data/.moves/seeds-floor.min-release ] || /opt/carlos/carlos.prev system floor-allows-prev /data/.moves/seeds-floor.min-release /opt/carlos/carlos.prev.seeds-allowed; }; then exec /opt/carlos/carlos.prev system apply --recover; fi; exec /opt/carlos/carlos system apply --recover'
ProtectHome=yes
PrivateTmp=yes
diff --git a/internal/host/units/carlos-system-update.service b/internal/host/units/carlos-system-update.service
index aa1f16c94..19be448cf 100644
--- a/internal/host/units/carlos-system-update.service
+++ b/internal/host/units/carlos-system-update.service
@@ -44,7 +44,7 @@ OnFailure=carlos-system-recover.service
Type=oneshot
TimeoutStartSec=15min
EnvironmentFile=-/etc/carlos/host.env
-ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && [ ! -L /data/.moves/floor.min-release ] && { [ ! -e /data/.moves/floor.min-release ] || [ -f /data/.moves/floor.min-release ]; } && { ! [ -s /data/.moves/floor.min-release ] || /opt/carlos/carlos.prev system floor-allows-prev /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; } && [ ! -L /data/.moves/seeds-floor.min-release ] && { [ ! -e /data/.moves/seeds-floor.min-release ] || [ -f /data/.moves/seeds-floor.min-release ]; } && { ! [ -s /data/.moves/seeds-floor.min-release ] || /opt/carlos/carlos.prev system floor-allows-prev /data/.moves/seeds-floor.min-release /opt/carlos/carlos.prev.seeds-allowed; }; then exec /opt/carlos/carlos.prev system apply; fi; exec /opt/carlos/carlos system apply'
+ExecStart=/bin/sh -c 'if [ -e /var/lib/carlos-claims/authority/exec/census.json ] || [ -L /var/lib/carlos-claims/authority/exec/census.json ] || [ -e /opt/carlos/system/guard/moves ] || [ -L /opt/carlos/system/guard/moves ]; then exec /opt/carlos/system/guard/carlos system guard-exec apply; fi; if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && [ ! -L /data/.moves/floor.min-release ] && { [ ! -e /data/.moves/floor.min-release ] || [ -f /data/.moves/floor.min-release ]; } && { ! [ -s /data/.moves/floor.min-release ] || /opt/carlos/carlos.prev system floor-allows-prev /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; } && [ ! -L /data/.moves/seeds-floor.min-release ] && { [ ! -e /data/.moves/seeds-floor.min-release ] || [ -f /data/.moves/seeds-floor.min-release ]; } && { ! [ -s /data/.moves/seeds-floor.min-release ] || /opt/carlos/carlos.prev system floor-allows-prev /data/.moves/seeds-floor.min-release /opt/carlos/carlos.prev.seeds-allowed; }; then exec /opt/carlos/carlos.prev system apply; fi; exec /opt/carlos/carlos system apply'
# --- hardening (the carlos-stage.service set) ---
ProtectHome=yes
diff --git a/internal/provision/plan14_p3_census_test.go b/internal/provision/plan14_p3_census_test.go
new file mode 100644
index 000000000..89c7b52cd
--- /dev/null
+++ b/internal/provision/plan14_p3_census_test.go
@@ -0,0 +1,27 @@
+package provision
+
+import "testing"
+
+func TestPlan14P3OverlayPreservesLegacySeal(t *testing.T) {
+ root := censusRoot(t)
+ inv := rootReadInventory{Entries: map[string]classifiedRootRead{}, Coverage: map[string]string{}}
+ for i, name := range []string{"plan14-root-reads.tsv", "plan14-root-reads-moves.tsv", "plan14-root-reads-merge.tsv", "plan14-root-reads-fix1.tsv", "plan14-root-reads-fix2.tsv", "plan14-root-reads-merge3.tsv", "plan14-root-reads-fix4.tsv", "plan14-root-reads-fix5.tsv", "plan14-root-reads-fix6.tsv", "plan14-root-reads-zddr3.tsv", "plan14-root-reads-hotfix281.tsv", "plan14-root-reads-zddr4.tsv", "plan14-root-reads-reserved285.tsv", "plan14-root-reads-292.tsv", "plan14-root-reads-census-ruling-275.tsv", "plan14-root-reads-census-ruling-owed.tsv", "plan14-root-reads-carlos-run.tsv", "plan14-root-reads-seeded-instances.tsv", "plan14-root-reads-hostfiles-cap-236.tsv", "plan14-root-reads-e.tsv", "plan14-root-reads-p3.tsv"} {
+ if err := loadRootReadInventory(root+"/internal/provision/testdata/"+name, &inv, i > 0); err != nil {
+ t.Fatal(err)
+ }
+ if name == "plan14-root-reads-e.tsv" || name == "plan14-root-reads-p3.tsv" {
+ if errs := checkRootReadLegacy(inv, "10e024eccd584f40b64e959a105c551302585e3e14e55104fc250dae5960898a"); len(errs) != 0 {
+ t.Fatal(errs)
+ }
+ }
+ }
+}
+
+// The conservative write scanner recognizes the local helper's name as a
+// record-key site. These calls write root floor files, never instance records.
+func plan14P3WriteCensusInventory() []censusEntry {
+ return append(plan14EWriteCensusInventory(),
+ censusEntry{"internal/host/rootmovesfloor.go", "rootMovesFloors.persist", "key-site", "key:writeRecord", "2c1e0843c5865a31c0fab011d7d4825963bbb5a301727f37f3b1b6c3519eba5e"},
+ censusEntry{"internal/host/rootmovesfloor.go", "rootMovesFloors.writeRecord", "key-site", "key:writeRecord", "a8a51bdd73f8ab1447edf2241d19a41ba1bcb0084ef045d370e5282aca7e9e66"},
+ )
+}
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index 8dc80626b..34d1c3e3c 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -745,6 +745,9 @@ func TestPlan14RootReadCensus(t *testing.T) {
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-e.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
+ if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-p3.tsv"), &inv, true); err != nil {
+ t.Fatal(err)
+ }
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 11c0eea4f..3ca18e8f0 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -366,7 +366,7 @@ func TestPlan9B8RecordWriteCensus(t *testing.T) {
if len(got) < 100 {
t.Fatalf("census found only %d units under %s; the walk is broken", len(got), root)
}
- inventory := plan14EWriteCensusInventory()
+ inventory := plan14P3WriteCensusInventory()
problems := append(censusDiff(inventory, got), censusPolicy(inventory)...)
if len(problems) > 0 {
t.Fatalf("record-write census rejected %d site(s); update the explicit inventory only after reviewing the source:\n %s", len(problems), strings.Join(problems, "\n "))
diff --git a/internal/provision/testdata/plan14-root-reads-p3.tsv b/internal/provision/testdata/plan14-root-reads-p3.tsv
new file mode 100644
index 000000000..a375dbb96
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-p3.tsv
@@ -0,0 +1,54 @@
+# Plan 14 Part 3 run 1; all entries require controller ruling.
+# No accepted-legacy fingerprints changed. Load after plan14-root-reads-e.tsv.
+internal/console/boxselect.go Server.selectMoveBoxes authority F,J4 rev3=PROPOSED-p3;identity=console service user;basis=refuses missing/quarantined accepted signal, generation zero, stale floor or incompatible prev at authoritative move admission 7e3e9c39c9b6d79b99fa00fae233c109d7169659f646b49e43ec7ec320c723fa
+internal/host/guardarm.go RunSystemGuardArm authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:OpenFile@565,file:ReadFile@1142,file:ReadFile@1756,file:ReadFile@5797,file:ReadFile@865,file:Stat@3448 3eb11b09adc3b122f555513cc35aac8124f89149e72db995351cde384b2cf809
+internal/host/guardexec.go RunSystemGuardExec authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Exec@1896,file:Exec@2049,file:Stat@952 00fb661d2fd0e57450beb310eb39f9a2152001a67632329777d2c814f3c178d9
+internal/host/guardtransaction.go systemApplier.guardRun authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors c0a0ea8fe6a524d95b3a579ecf296c8075e435478c6daae2b0fa06518e88314f
+internal/host/movesfloor.go rootJudgeMovesPrev authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=census-only branch selects independent refresh; no-census body remains main behavior; moved legacy body remains F authority 0c9aa7c7df1ddd5b21798bc0158c5496dad16d400a69e8ace2dd61e8e16f0d58
+internal/host/movesfloor.go rootJudgeMovesPrevWithSource authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=census-only branch selects independent refresh; no-census body remains main behavior; moved legacy body remains F authority file:Stat@289,file:Stat@490 fa942c1c4b8af2d2930be427e303f3b9838c0fce33f1b3f8e63e17093b638269
+internal/host/movesfloor.go rootJudgePrev authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=census-only branch selects independent refresh; no-census body remains main behavior; moved legacy body remains F authority 8b5ecf5a0f33ee58d603f308302bcc8e68678da6b1e6ee7ed03845c35747f8c6
+internal/host/movesfloor.go withMovesFields authority F,J4 rev3=PROPOSED-p3;identity=service user, edges publisher (also uid 0 readers);basis=reads only root-owned accepted projection and generation-bound verdict; directory sync precedes acknowledgement; agent mirror remains diagnostic file:Lstat@238,file:Stat@697 3e615afa769bb64bcb0ffdc1ad7b85b67883a34cd6afd83ce7da74a1f4218983
+internal/host/rootmovesfloor.go openRootMovesFloors authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Open@167,file:Open@269 4ceed7e158d3cfc0b9b77df47c7656aea044f95e0706ed3cb9e1df698995f42a
+internal/host/rootmovesfloor.go rootMovesFloors.persist authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors 2c1e0843c5865a31c0fab011d7d4825963bbb5a301727f37f3b1b6c3519eba5e
+internal/host/rootmovesfloor.go rootMovesFloors.projection authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Read@109 6b0ed0053db247385ccbabfd95a738fd48a8d27b14882b5e73b4e5a82eda26b6
+internal/host/rootmovesfloor.go rootMovesFloors.reconstruct authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors e14cbe7dfcbcaf50bff3e1318da0080f184ac23a654ac1f5d9f333c71e3f549a
+internal/host/rootmovesfloor.go rootMovesFloors.record authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Read@108 2e2bc3e9e915e4a0a70c40933de3928284bbdab25ee223e4cf05500a12913b83
+internal/host/rootmovesfloor.go rootMovesFloors.refresh authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors b4afeaa9dcd9fd5f33ae3d8d8810224e6025adf4ff5b8dc7671e95cc96ff2777
+internal/host/rootmovesfloor.go rootMovesFloors.selector authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Read@73 5c46438590323610c67dc5bbfaa191fc5ba23e3b280b6063dbd48e48e7ce3927
+internal/host/rootmovesfloor_boot.go rootFloorAllowsPrev authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors 8fb85b6abbc251b4456d6ad6cce02331048804870e6b64a56e12f8fda0107d0d
+internal/host/rootmovesfloor_boot.go rootFloorBootCandidate authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors 80607853f76e6ffb588c8168a557107f66a12c34acdd6fafee390ae5bfa5d612
+internal/host/rootmovesfloor_boot.go rootFloorJournalPresent authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors b189d04dc579f4825f5668efd1f93f178acca738bb2ed886a14f3b5321d7755b
+internal/host/rootmovesfloor_boot.go rootFloorPrevVerdict authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Read@131 04a9909b6ff4c81380c1d974ce028ae525ff53513fb71f78b0e1076d35391967
+internal/host/rootmovesfloor_boot.go withRootFloorGuardCandidate authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Open@218 92937e2a80d01e17d839a43cf42bb81974b289266cad60dff81c6c60cfb37e6f
+internal/host/rootmovesfloor_identity.go rememberRootFloorSystemIdentity authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Open@543 b4e56a0637c28e5fb65d3a33b7dfd1fea8bed55acbf5231bf26e0011168141fe
+internal/host/rootmovesfloor_refresh.go readRootFloorBinary authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Open@684,file:Read@941,file:Stat@276 47062453004252de69e807a67ac94e40243d69b3a55033dacf2d8354a5d635a6
+internal/host/rootmovesfloor_refresh.go rootFloorAdvertisementFor authority F,J4 rev3=PROPOSED-p3;identity=service user, edges publisher (also uid 0 readers);basis=reads only root-owned accepted projection and generation-bound verdict; directory sync precedes acknowledgement; agent mirror remains diagnostic file:Read@203 4e464b6add24a06b1c3d7a5859be7a1055750bc25f27c8ca641241be3baa2ef8
+internal/host/rootmovesfloor_refresh.go rootFloorDowngradeIdle authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Open@68 8bb82e0b179e0dfadd6f375299d9f40ceea1c2b911b1d53f18fb4958957c74ac
+internal/host/rootmovesfloor_refresh.go rootFloorJudge.judge authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors 0c7aa349e001578024ea11cd91b25cf01cc67fa8adcc4db4e8190eb4d16d7621
+internal/host/rootmovesfloor_refresh.go rootFloorJudge.refresh authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors 362798a60b4e3d6fc798db52bfc204979f724efe33a858f3d7ca2c1ca12f6eaa
+internal/host/rootmovesfloor_refresh.go rootRefreshAndJudgeMovesPrev authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors 2d4d1ce1d6143555f3b94b19359c014ffb20272f41f756f7c02bbf6ff29a70a9
+internal/host/rootmovesfloor_refresh.go withRootMovesFields authority F,J4 rev3=PROPOSED-p3;identity=service user, edges publisher (also uid 0 readers);basis=reads only root-owned accepted projection and generation-bound verdict; directory sync precedes acknowledgement; agent mirror remains diagnostic file:Open@405 29fb652e22df888f18c691b91db6b83b9781d89d005a2fae431d8102e77b2f88
+internal/host/rootmovesfloor_source.go readEffectiveMovesFloor authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors 47496b77002d64977e309e1edc70a6fb2c87e7748575b5dce60a4e75ee89ecac
+internal/host/rootmovesfloor_source.go readRootFloorProjection authority F,J4 rev3=PROPOSED-p3;identity=service user, edges publisher (also uid 0 readers);basis=reads only root-owned accepted projection and generation-bound verdict; directory sync precedes acknowledgement; agent mirror remains diagnostic file:Open@83,file:Read@243 170e020e2f05437de39145566fdd4055cc11bd668fe24a1ca2458ece49195aeb
+internal/host/rootmovesfloor_source.go readRootLegacyFloorJSON authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:ReadAll@443,file:Stat@280 1af52c869107011648314ac12a9687ce641329b5a92fa7d487c278fa082bf4f2
+internal/host/rootmovesfloor_source.go refreshRootMovesFloor authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors a41df2c69e7d869b5232d545440b8135e8c7124d490965e641b8d1fba83cb490
+internal/host/rootmovesfloor_source.go rootFloorLegacy authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:ReadAll@710,file:Stat@518 37e37f7b57fb768eea5cf750fb1a88a1a43b4239180878f5479a77f276dce424
+internal/host/rootmovesfloor_source.go rootMovesFloorIdentity authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors 457cdc76e2cff8fdfe4d146c7baf086dd58d54f151ce9416c32efd0a03778d98
+internal/host/rootmovesfloor_source.go systemApplier.refreshRootFloorForRecovery authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors eb0e38f5184c8036d0611973a46bd66b926273263edcada89cc0b6d54f189b24
+internal/host/rootmovesfloor_source.go withEffectiveMovesFloorLock authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors dbadcd8e7e8844502ee5006eb7e2d2a5f5312f91316bcfc41937035b34a85206
+internal/host/systemapply.go RunSystemApply authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Open@1265 22440d8ad127c66958b9c4d06244db4ca1aec1b9dfd60fad8f5bceb24f789b25
+internal/host/systemapply.go systemApplier.activationGuard authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=promote only protected F floor/downgrade effect from frozen legacy; preserve original fingerprint and no-census behavior; unrelated registry decisions remain legacy whole:Route@1083 49988cef27c704d1c937176f1bcad1d22a229beb18a8ef2a2ef949cd39fae9e6
+internal/host/systemapply.go systemApplier.activationRule authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=promote only protected F floor/downgrade effect from frozen legacy; preserve original fingerprint and no-census behavior; unrelated registry decisions remain legacy whole:Route@1019,whole:Route@810,whole:Route@817 bbe33714cf851396d775273545cece1aeed399f275bdaf303e126dc724ee0961
+internal/host/systemapply.go systemApplier.armFloor authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors d7e16eaaa3994896bf9acd763777cc4d8998056dfa2d492c5320395102009a16
+internal/host/systemapply.go systemApplier.layoutReversalReasons authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors ee4e5ee2961e86b6d2e700601d57bb605c3c945515ffc678d35919d70dc2b0af
+internal/host/systemapply.go systemApplier.notePrev authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors d55d08f057fb6ea467e293b4e7f0ca3e2e639d26147cab2d1e823530404ff2db
+internal/host/systemapply.go systemApplier.restoreFiles authority F,J4 rev3=PROPOSED-p3;identity=uid 0, adopt/apply/recovery;basis=root floor persistence, signed executable selection or census-gated enforcement; TestPlan14RootFloorCrashOrdering, TestPlan14FloorRiseWithoutPlatformUpdate, TestPlan14RootFloorBootSelectors file:Stat@319 95f8a9a2262e5472ca32b68ce8032d9928f84da894ff9bfaec291c010b448337
+internal/host/units/carlos-system-recover.service external authority F,J4 rev3=PROPOSED-p3;identity=uid 0, installed systemd selector;basis=root marker/census selects retained guard reader before any legacy absence branch; embedded bootstrap output; TestPlan14RootFloorBootSelectors file:external@0 48ea0ae9be57a99f5910b442e16ba3a79f7ddd4c30a2c6910fbcba612bcb247d
+internal/host/units/carlos-system-update.service external authority F,J4 rev3=PROPOSED-p3;identity=uid 0, installed systemd selector;basis=root marker/census selects retained guard reader before any legacy absence branch; embedded bootstrap output; TestPlan14RootFloorBootSelectors file:external@0 b2ede25244b33563fa29920fbeaa59f687f6d0f398607fc76590b56d90f1c37c
+internal/rootstate/state.go Sync authority F,J4 rev3=PROPOSED-p3;identity=uid 0 writer / service-user projection reader;basis=sync existing checked descriptor; no ownership change, new file or permission grant 2e5d0a1411eff4cfb942f20517ecc2653a05ae3e4d586c14092d65de48ceff02
+internal/rootstate/state_linux.go directory.Sync authority F,J4 rev3=PROPOSED-p3;identity=uid 0 writer / service-user projection reader;basis=sync existing checked descriptor; no ownership change, new file or permission grant b932169c2132a038eb12459c8537f0f308d4ba5987d5d3f296f2bd435f598fe3
+@coverage internal/console 478ab8732720175aa6c9ac9e49364b9401b69230e7939b17b2f04c9d4c8c91fd
+@coverage internal/edgesfile 46558f38ee9cef8def5a1915a2545e91795ede008b4b296f102a7ffd45dd1b82
+@coverage internal/host f3a1d713d0139a5ceb8090e826e54c2a471d355b49451f9af806f645dcbedc79
+@coverage internal/host/units 4ec2973162254c97bad311eb1e10536e5aea1e53f4c4614238e350e94a8e0095
+@coverage internal/rootstate 058f59efad955ffeb098d79b4bda9dd9407078fb302dea501624b81297be4cf7
diff --git a/internal/rootstate/state.go b/internal/rootstate/state.go
index 24ca9aa22..ddecbae67 100644
--- a/internal/rootstate/state.go
+++ b/internal/rootstate/state.go
@@ -18,6 +18,15 @@ type Store interface {
Close() error
}
+// Sync makes a checked directory's visible entries durable. A read-only
+// projection consumer uses it before acknowledging a newly observed record.
+func Sync(s Store) error {
+ if d, ok := s.(interface{ Sync() error }); ok {
+ return d.Sync()
+ }
+ return os.ErrPermission
+}
+
func component(s string) bool {
if s == "" || s == "." || s == ".." || len(s) > 255 {
return false
diff --git a/internal/rootstate/state_linux.go b/internal/rootstate/state_linux.go
index aa5701f8f..d86aab80d 100644
--- a/internal/rootstate/state_linux.go
+++ b/internal/rootstate/state_linux.go
@@ -117,6 +117,12 @@ func descendSync(parent *os.File, parts []string, create, public bool, owner uin
}
func (d *directory) Close() error { return d.file.Close() }
+func (d *directory) Sync() error {
+ if err := d.check(); err != nil {
+ return err
+ }
+ return d.file.Sync()
+}
func (d *directory) check() error { return trusted(int(d.file.Fd()), d.owner, unix.S_IFDIR) }
func (d *directory) Read(name string) ([]byte, error) {
if !component(name) {