carlos / platform Public

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 2e576bdedf4d9ffd0d5f9da58f24a569329b8739 parent 24d29fb
9 files changed, +573 −13
  • internal/host/plan14_generation_registry_root_linux_test.go +69 −0
  • internal/host/plan14_generation_requirements_root_linux_test.go +123 −8
  • internal/host/plan14_generation_rows_test.go +248 −0
  • internal/host/registry_privilege_test.go +1 −0
  • internal/host/rootgeneration_inventory.go +5 −1
  • internal/host/rootgeneration_rows.go +100 −0
  • internal/provision/testdata/plan14-root-reads-e.tsv +5 −2
  • internal/registry/readonly_test.go +21 −1
  • internal/registry/remote.go +1 −1

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.012s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	352.414s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.237s
ok  	github.com/carlosframework/platform/cmd/moveprobe	93.338s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.705s
ok  	github.com/carlosframework/platform/internal/activator	234.004s
ok  	github.com/carlosframework/platform/internal/agentsock	0.014s
ok  	github.com/carlosframework/platform/internal/alerting	0.003s
ok  	github.com/carlosframework/platform/internal/analytics	0.019s
ok  	github.com/carlosframework/platform/internal/appconfig	2.391s
ok  	github.com/carlosframework/platform/internal/apperr	0.074s
ok  	github.com/carlosframework/platform/internal/applog	0.134s
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.854s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.008s
ok  	github.com/carlosframework/platform/internal/caps	0.013s
ok  	github.com/carlosframework/platform/internal/certwarm	0.003s
ok  	github.com/carlosframework/platform/internal/cgroup	0.707s
ok  	github.com/carlosframework/platform/internal/changelog	0.303s
ok  	github.com/carlosframework/platform/internal/clamd	0.055s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	367.134s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	6.450s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.010s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.003s
ok  	github.com/carlosframework/platform/internal/docsite	0.004s
ok  	github.com/carlosframework/platform/internal/domains	2.169s
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.043s
?   	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.124s
ok  	github.com/carlosframework/platform/internal/gate	1.655s
ok  	github.com/carlosframework/platform/internal/genselect	0.015s
ok  	github.com/carlosframework/platform/internal/geo	0.017s
ok  	github.com/carlosframework/platform/internal/geoip	0.029s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.155s
ok  	github.com/carlosframework/platform/internal/host	135.285s
ok  	github.com/carlosframework/platform/internal/hostactors	0.379s
ok  	github.com/carlosframework/platform/internal/hostbox	2.354s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.525s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.005s
ok  	github.com/carlosframework/platform/internal/hostexec	4.862s
ok  	github.com/carlosframework/platform/internal/hostfence	0.031s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.447s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.012s
ok  	github.com/carlosframework/platform/internal/hostguard	0.981s
ok  	github.com/carlosframework/platform/internal/hostops	1.481s
?   	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.455s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.142s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.005s
ok  	github.com/carlosframework/platform/internal/infraview	0.002s
ok  	github.com/carlosframework/platform/internal/instancectl	1.820s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.741s
ok  	github.com/carlosframework/platform/internal/instancefs	0.021s
ok  	github.com/carlosframework/platform/internal/instancekey	0.019s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.030s
ok  	github.com/carlosframework/platform/internal/layout	1.439s
ok  	github.com/carlosframework/platform/internal/lease	9.443s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.009s
ok  	github.com/carlosframework/platform/internal/ledger	0.010s
ok  	github.com/carlosframework/platform/internal/mail	0.165s
ok  	github.com/carlosframework/platform/internal/maintenance	0.079s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.532s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.092s
ok  	github.com/carlosframework/platform/internal/metrics	0.006s
ok  	github.com/carlosframework/platform/internal/moves	21.177s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.765s
ok  	github.com/carlosframework/platform/internal/objstore	0.172s
ok  	github.com/carlosframework/platform/internal/ops	0.160s
ok  	github.com/carlosframework/platform/internal/park	0.193s
ok  	github.com/carlosframework/platform/internal/privatefile	0.004s
ok  	github.com/carlosframework/platform/internal/provision	14.067s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.006s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	4.029s
ok  	github.com/carlosframework/platform/internal/relay	7.231s
ok  	github.com/carlosframework/platform/internal/release	21.763s
ok  	github.com/carlosframework/platform/internal/rollout	3.722s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.006s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.008s
ok  	github.com/carlosframework/platform/internal/rootstate	0.024s
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.299s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.689s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.837s
ok  	github.com/carlosframework/platform/internal/status	0.038s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.192s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.005s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.562s
=== 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
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.025s
=== RUN   TestT4StorageProbeMinio
2026/10/14 18:55:27 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE7A426072CA88, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.022s
minio contract: PASS
diff --git a/internal/host/plan14_generation_registry_root_linux_test.go b/internal/host/plan14_generation_registry_root_linux_test.go
new file mode 100644
index 000000000..6829112e4
--- /dev/null
+++ b/internal/host/plan14_generation_registry_root_linux_test.go
@@ -0,0 +1,69 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "os/user"
+ "strconv"
+ "testing"
+)
+
+// The test binary must take the same private registry entrypoint as carlos.
+func TestMain(m *testing.M) {
+ if len(os.Args) > 1 && os.Args[1] == registryChildCommand {
+ if err := RunRegistryChild(os.Args[2:]); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ os.Exit(0)
+ }
+ os.Exit(m.Run())
+}
+
+func generationRunnerRegistryUser(t *testing.T) (int, int) {
+ t.Helper()
+ if os.Geteuid() != 0 {
+ t.Fatal("designated runner must execute as root")
+ }
+ service, err := user.Lookup(serviceUser)
+ if err != nil {
+ if _, ok := err.(user.UnknownUserError); !ok {
+ t.Fatal(err)
+ }
+ if _, err := user.LookupGroup(serviceUser); err == nil {
+ t.Fatal("service group exists without its user")
+ } else if _, ok := err.(user.UnknownGroupError); !ok {
+ t.Fatal(err)
+ }
+ if out, err := exec.Command("useradd", "--system", "--user-group", "--no-create-home", "--shell", "/usr/sbin/nologin", serviceUser).CombinedOutput(); err != nil {
+ t.Fatalf("create runner service user: %v %s", err, out)
+ }
+ t.Cleanup(func() {
+ if out, err := exec.Command("userdel", serviceUser).CombinedOutput(); err != nil {
+ t.Errorf("remove runner service user: %v %s", err, out)
+ return
+ }
+ if _, err := user.LookupGroup(serviceUser); err == nil {
+ if out, err := exec.Command("groupdel", serviceUser).CombinedOutput(); err != nil {
+ t.Errorf("remove runner service group: %v %s", err, out)
+ }
+ }
+ })
+ service, err = user.Lookup(serviceUser)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ uid, err := strconv.Atoi(service.Uid)
+ if err != nil || uid <= 0 {
+ t.Fatal("runner service UID must be non-root", err)
+ }
+ gid, err := strconv.Atoi(service.Gid)
+ if err != nil || gid < 0 {
+ t.Fatal("invalid runner service GID", err)
+ }
+ return uid, gid
+}
diff --git a/internal/host/plan14_generation_requirements_root_linux_test.go b/internal/host/plan14_generation_requirements_root_linux_test.go
index 69d195bf0..6ba8c5163 100644
--- a/internal/host/plan14_generation_requirements_root_linux_test.go
+++ b/internal/host/plan14_generation_requirements_root_linux_test.go
@@ -14,6 +14,7 @@ import (
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostguard"
"github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/rootparticipation"
"github.com/carlosframework/platform/internal/rootstate"
@@ -24,7 +25,20 @@ func TestPlan14GenerationRequiredArtifactsRootRunner(t *testing.T) {
const childKey = "PLAN14_E30_REQUIRED_CHILD"
dir := os.Getenv(childKey)
if dir == "" {
- dir = generationRootRunnerDir(t)
+ generationRunnerRegistryUser(t)
+ var err error
+ dir, err = os.MkdirTemp("/run", "plan14-generation-rows-")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := os.RemoveAll(dir); err != nil {
+ t.Error(err)
+ }
+ })
+ if err := os.Chmod(dir, 0755); err != nil {
+ t.Fatal(err)
+ }
binary, err := os.Executable()
if err != nil {
t.Fatal(err)
@@ -57,6 +71,7 @@ func TestPlan14GenerationRequiredArtifactsRootRunner(t *testing.T) {
t.Error(err)
}
})
+ t.Setenv("CARLOS_HOST_CLAIM", "true")
t.Setenv("CARLOS_OPT_DIR", dir)
t.Setenv("CARLOS_DATA_DIR", filepath.Join(dir, "data"))
t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
@@ -66,6 +81,18 @@ func TestPlan14GenerationRequiredArtifactsRootRunner(t *testing.T) {
t.Fatal(err)
}
}
+ uid, gid := generationRunnerRegistryUser(t)
+ if err := os.Chown(dataDir(), uid, gid); err != nil {
+ t.Fatal(err)
+ }
+ reg, err := openRegistryChild(filepath.Join(dataDir(), "registry.db"), uid, gid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Join the fixture writer before the production kernel scan.
+ if err := reg.Close(); err != nil {
+ t.Fatal(err)
+ }
for _, path := range []string{rootparticipation.Namespace, "/var/lib/carlos-claims/authority/fences", release.RootGenerationsDir} {
state, err := rootstate.Open(path, true, false)
if err != nil {
@@ -140,7 +167,32 @@ func TestPlan14GenerationRequiredArtifactsRootRunner(t *testing.T) {
t.Fatal(err)
}
}
+ payload.Entries = append(payload.Entries, hostbox.StopInventoryEntry{Kind: "timer", ArtifactID: "timer", ManagedRoot: "registry", RelativePath: in.Host + "/backup", RowIdentity: "timer", Ownership: "owner", Materialization: "materialized"})
put(payload)
+ owner := registry.ClaimOwnership{ClaimIncarnation: inc, ClaimStamp: 2, ClaimOwner: "owner", ClaimRowID: "row"}
+ route := registry.Route{Host: in.Host, ClaimOwnership: owner}
+ timer := registry.Timer{Host: in.Host, Name: "backup", Kind: "cron", Registration: "timer", ClaimOwnership: owner}
+ timer.ClaimRowID = "timer"
+ rows := func(rt registry.Route, timers []registry.Timer) {
+ t.Helper()
+ writer, err := openRegistryChild(filepath.Join(dataDir(), "registry.db"), uid, gid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer writer.Close()
+ if err := writer.Delete(in.Host); err != nil {
+ t.Fatal(err)
+ }
+ if err := writer.ReplaceTimers(in.Host, timers); err != nil {
+ t.Fatal(err)
+ }
+ if rt.Host != "" {
+ if err := writer.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ rows(route, []registry.Timer{timer})
certificate := func() release.GenerationCompleteness {
t.Helper()
state, err := rootstate.Open(release.RootGenerationsDir, false, false)
@@ -155,24 +207,34 @@ func TestPlan14GenerationRequiredArtifactsRootRunner(t *testing.T) {
return c
}
source := productionRootGenerationPins()
+ pinsUnderRelease := func() (release.StagedPaths, error) {
+ var pins release.StagedPaths
+ err := withClaimRootRelease(t.Context(), true, func() (rootstate.Store, error) { return rootstate.Open(rootReleasePrivate, false, false) }, func() error {
+ var err error
+ pins, err = source.StagedPins(t.Context(), dir)
+ return err
+ })
+ return pins, err
+ }
accept := func() {
t.Helper()
- if pins, err := source.StagedPins(t.Context(), dir); err != nil || len(pins) != 0 || !certificate().Complete {
+ if pins, err := pinsUnderRelease(); err != nil || len(pins) != 0 || !certificate().Complete {
t.Fatalf("production required artifacts: %v %v", pins, err)
}
}
accept()
- for _, omit := range []string{"route", "state"} {
+ for _, omit := range []string{"route", "state", "timer"} {
t.Run(omit, func(t *testing.T) {
initial := certificate()
missing := payload
- if omit == "route" {
- missing.Entries = missing.Entries[1:]
- } else {
- missing.Entries = missing.Entries[:1]
+ missing.Entries = nil
+ for _, entry := range payload.Entries {
+ if entry.ArtifactID != omit {
+ missing.Entries = append(missing.Entries, entry)
+ }
}
put(missing)
- if pins, err := source.StagedPins(t.Context(), dir); err == nil || pins != nil {
+ if pins, err := pinsUnderRelease(); err == nil || pins != nil {
t.Fatalf("production omitted %s certified: %v %v", omit, pins, err)
}
if c := certificate(); c.Complete || c.Revision <= initial.Revision {
@@ -185,4 +247,57 @@ func TestPlan14GenerationRequiredArtifactsRootRunner(t *testing.T) {
accept()
})
}
+ for _, attack := range []string{"route-missing", "route-owner", "timer-missing", "timer-registration", "extra-timer", "malformed-database"} {
+ t.Run(attack, func(t *testing.T) {
+ initial := certificate()
+ rt, tm := route, timer
+ switch attack {
+ case "route-missing":
+ rt = registry.Route{}
+ case "route-owner":
+ rt.ClaimOwner = "forged"
+ case "timer-registration":
+ tm.Registration = "forged"
+ }
+ timers := []registry.Timer{tm}
+ if attack == "timer-missing" {
+ timers = nil
+ }
+ if attack == "extra-timer" {
+ extra := timer
+ extra.Name, extra.Registration, extra.ClaimRowID = "extra", "extra", "extra"
+ timers = append(timers, extra)
+ }
+ rows(rt, timers)
+ path := filepath.Join(dataDir(), "registry.db")
+ if attack == "malformed-database" {
+ if err := os.Rename(path, path+".saved"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("not sqlite"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chown(path, uid, gid); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if pins, err := pinsUnderRelease(); err == nil || pins != nil {
+ t.Fatalf("production %s certified: %v %v", attack, pins, err)
+ }
+ if c := certificate(); c.Complete || c.Revision <= initial.Revision {
+ t.Fatal("row mismatch retained certificate")
+ }
+ if attack == "malformed-database" {
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Rename(path+".saved", path); err != nil {
+ t.Fatal(err)
+ }
+ }
+ rows(route, []registry.Timer{timer})
+ accept()
+ })
+ }
+
}
diff --git a/internal/host/plan14_generation_rows_test.go b/internal/host/plan14_generation_rows_test.go
new file mode 100644
index 000000000..d50bf29af
--- /dev/null
+++ b/internal/host/plan14_generation_rows_test.go
@@ -0,0 +1,248 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rootparticipation"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func TestPlan14GenerationRegistryRows(t *testing.T) {
+ for _, attack := range []string{"none", "predecessor", "route-missing", "route-incarnation", "route-stamp", "route-owner", "route-id", "timer-missing", "timer-incarnation", "timer-stamp", "timer-owner", "timer-id", "timer-registration", "timer-extra", "route-entry-missing", "timer-entry-missing", "duplicate-entry", "timer-other-host", "wrong-root", "empty-installation", "unreadable", "changed-during-kernel", "row-next-pass", "timer-delivery-progress", "closed-registry"} {
+ t.Run(attack, func(t *testing.T) {
+ f, disk, _ := generationLaunchFixture(t)
+ if _, err := f.root.Serve(t.Context(), f.peer, f.req); err != nil {
+ t.Fatal(err)
+ }
+ reservation := readGenerationLaunch(t, disk)
+ views, err := f.root.Guard.Root.GenerationInstallations(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ launch, err := f.journal.Read(t.Context(), f.target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if attack == "predecessor" {
+ views[0].Retired = []hostguard.Captured{{Incarnation: f.target.Incarnation, Stamp: f.target.Stamp}}
+ views[0].Installation.Stamp++
+ views[0].Installation.Empty = true
+ }
+ payloads := generationPayloadFixture(views)
+ index := 0
+ if attack == "predecessor" {
+ index = 1
+ payloads[index].Inventory.Entries = []hostbox.StopInventoryEntry{{Kind: "route", ArtifactID: "route", ManagedRoot: "registry", RelativePath: f.target.Host, RowIdentity: "row", Ownership: "owner", Materialization: "materialized"}}
+ }
+ payloads[index].Inventory.Entries = append(payloads[index].Inventory.Entries, hostbox.StopInventoryEntry{Kind: "timer", ArtifactID: "timer", ManagedRoot: "registry", RelativePath: f.target.Host + "/backup", RowIdentity: "timer", Ownership: "owner", Materialization: "materialized"})
+ snapshot := generationInventory{Census: rootparticipation.Census{Schema: 1, Box: f.target.Box, Enrollment: f.target.Enrollment, Migration: 1, Revision: 1, LaunchRevision: 1, Complete: true}, Installations: views, Payloads: payloads, Launches: []hostbox.LaunchInventory{launch}}
+ registryPath := filepath.Join(t.TempDir(), "registry.db")
+ reg, err := registry.Open(registryPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reg.Close()
+ t.Setenv("CARLOS_HOST_CLAIM", "true")
+ owner := registry.ClaimOwnership{ClaimIncarnation: f.target.Incarnation, ClaimStamp: f.target.Stamp, ClaimOwner: "owner", ClaimRowID: "row"}
+ route := registry.Route{Host: f.target.Host, ClaimOwnership: owner}
+ timer := registry.Timer{Host: f.target.Host, Name: "backup", Kind: "cron", Registration: "timer", ClaimOwnership: owner}
+ timer.ClaimRowID = "timer"
+ put := func(rt registry.Route, timers []registry.Timer) {
+ t.Helper()
+ if err := reg.Delete(route.Host); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.ReplaceTimers(route.Host, timers); err != nil {
+ t.Fatal(err)
+ }
+ if rt.Host != "" {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ put(route, []registry.Timer{timer})
+ active := false
+ reader := reg
+ v := generationReconciler{read: func(ctx context.Context) (generationInventory, error) {
+ return generationRegistryInventory(ctx, reader, snapshot)
+ }, kernel: func(context.Context) (string, string, error) {
+ if active && attack == "changed-during-kernel" {
+ // The registry changes after the first read while root inventory stays fixed.
+ next := route
+ next.ClaimRowID = "replacement"
+ put(next, []registry.Timer{timer})
+ }
+ return "boot", reservation.Binding.Digest, nil
+ }}
+ source := generationPinSource{open: func() (rootstate.Store, error) { return disk, nil }, reconcile: v.reconcile}
+ ledger := &release.RootGenerations{Store: disk}
+ accept := func() {
+ t.Helper()
+ pins, err := source.StagedPins(t.Context(), f.root.generations.optDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if pinned, err := pins.Refers(reservation.Binding.Path); err != nil || !pinned {
+ t.Fatalf("reservation lost: %v", err)
+ }
+ if c, err := ledger.Completeness(); err != nil || !c.Complete {
+ t.Fatalf("row census not complete: %+v %v", c, err)
+ }
+ }
+ accept()
+ if attack == "none" || attack == "predecessor" {
+ return
+ }
+ initial, _ := ledger.Completeness()
+ saved, err := json.Marshal(snapshot)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rt, tm := route, timer
+ switch attack {
+ case "route-missing":
+ rt = registry.Route{}
+ case "route-incarnation":
+ rt.ClaimIncarnation = "other"
+ case "route-stamp":
+ rt.ClaimStamp++
+ case "route-owner":
+ rt.ClaimOwner = "other"
+ case "route-id":
+ rt.ClaimRowID = "other"
+ case "timer-incarnation":
+ tm.ClaimIncarnation = "other"
+ case "timer-stamp":
+ tm.ClaimStamp++
+ case "timer-owner":
+ tm.ClaimOwner = "other"
+ case "timer-id":
+ tm.ClaimRowID = "other"
+ case "timer-registration":
+ tm.Registration = "other"
+ case "route-entry-missing":
+ snapshot.Payloads[index].Inventory.Entries = snapshot.Payloads[index].Inventory.Entries[1:]
+ case "timer-entry-missing":
+ snapshot.Payloads[index].Inventory.Entries = snapshot.Payloads[index].Inventory.Entries[:1]
+ case "duplicate-entry":
+ snapshot.Payloads[index].Inventory.Entries = append(snapshot.Payloads[index].Inventory.Entries, snapshot.Payloads[index].Inventory.Entries[1])
+ case "timer-other-host":
+ snapshot.Payloads[index].Inventory.Entries[1].RelativePath = "other.example/backup"
+ case "wrong-root":
+ snapshot.Payloads[index].Inventory.Entries[1].ManagedRoot = "data"
+ case "empty-installation":
+ snapshot.Installations[0].Installation.Empty = true
+ snapshot.Payloads[0].Inventory.Entries = nil
+ snapshot.Launches = nil
+ case "unreadable":
+ reader = nil
+ case "row-next-pass":
+ tm.Registration, tm.ClaimRowID = "replacement", "replacement"
+ snapshot.Payloads[index].Inventory.Entries[1].RowIdentity = "replacement"
+ case "timer-delivery-progress":
+ tm.NextAt++
+ tm.Attempts++
+ tm.LastStatus = "running"
+ }
+ timers := []registry.Timer{tm}
+ if attack == "timer-missing" {
+ timers = nil
+ }
+ if attack == "timer-extra" {
+ extra := timer
+ extra.Name, extra.Registration, extra.ClaimRowID = "extra", "extra", "extra"
+ timers = append(timers, extra)
+ }
+ put(rt, timers)
+ if attack == "closed-registry" {
+ if err := reg.Close(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if attack == "row-next-pass" || attack == "timer-delivery-progress" {
+ accept()
+ next, _ := ledger.Completeness()
+ changed := next.Evidence.Installations != initial.Evidence.Installations
+ if changed != (attack == "row-next-pass") {
+ t.Fatalf("row identity digest changed=%v for %s", changed, attack)
+ }
+ return
+ }
+ active = true
+ if pins, err := source.StagedPins(t.Context(), f.root.generations.optDir); err == nil || pins != nil {
+ t.Fatalf("registry mismatch certified: %v %v", pins, err)
+ }
+ if c, err := ledger.Completeness(); err != nil || c.Complete || c.Revision <= initial.Revision {
+ t.Fatalf("invalid rows retained certificate: %+v %v", c, err)
+ }
+ if got, err := ledger.Read(reservation.Binding); err != nil || got.Binding != reservation.Binding || got.Closed {
+ t.Fatal("row refusal changed reservation")
+ }
+ if attack == "closed-registry" {
+ reg, err = registry.Open(registryPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reg.Close()
+ }
+ active, reader = false, reg
+ if err := json.Unmarshal(saved, &snapshot); err != nil {
+ t.Fatal(err)
+ }
+ put(route, []registry.Timer{timer})
+ accept()
+ })
+ }
+}
+
+func TestPlan14GenerationRegistryReadOnly(t *testing.T) {
+ t.Setenv("CARLOS_DATA_DIR", t.TempDir())
+ if _, err := productionGenerationRegistryInventory(t.Context(), generationInventory{}); err == nil {
+ t.Fatalf("missing registry: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(dataDir(), "registry.db")); !os.IsNotExist(err) {
+ t.Fatalf("read created registry: %v", err)
+ }
+}
+
+func TestPlan14GenerationRegistryProductionReader(t *testing.T) {
+ t.Setenv("CARLOS_DATA_DIR", t.TempDir())
+ path := filepath.Join(dataDir(), "registry.db")
+ reg, err := registry.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ owner := registry.ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: "owner", ClaimRowID: "row"}
+ if err := reg.Upsert(registry.Route{Host: "tenant.example", ClaimOwnership: owner}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Close(); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ snapshot := generationInventory{Payloads: []hostbox.GenerationPayload{{Inventory: hostbox.StopInventory{Host: "tenant.example", Incarnation: "inc", InstallationStamp: 1, Entries: []hostbox.StopInventoryEntry{{Kind: "route", ManagedRoot: "registry", RelativePath: "tenant.example", RowIdentity: "row", Ownership: "owner", Materialization: "materialized"}}}}}}
+ if _, err := productionGenerationRegistryInventory(t.Context(), snapshot); err != nil {
+ t.Fatal(err)
+ }
+ snapshot.Payloads[0].Inventory.Entries = nil
+ if _, err := productionGenerationRegistryInventory(t.Context(), snapshot); err == nil {
+ t.Fatal("empty retained inventory erased a present row")
+ }
+ after, err := os.ReadFile(path)
+ if err != nil || !bytes.Equal(raw, after) {
+ t.Fatal("read-only source changed registry", err)
+ }
+}
diff --git a/internal/host/registry_privilege_test.go b/internal/host/registry_privilege_test.go
index a6dffe563..818a1755b 100644
--- a/internal/host/registry_privilege_test.go
+++ b/internal/host/registry_privilege_test.go
@@ -100,6 +100,7 @@ func TestPrivilegedRegistryEntryPointsUseServiceBoundary(t *testing.T) {
{"systemapply.go", "openSystemRegistry", "openServiceRegistry"},
{"claimstop_run.go", "RunInstanceControl", "openServiceRegistry"},
{"claimstop_run.go", "instanceRegistryHostApp", "openServiceRegistryReadOnly"},
+ {"rootgeneration_rows.go", "productionGenerationRegistryInventory", "openServiceRegistryReadOnly"},
{"claimjournal_migration.go", "RunSystemJournalMigrate", "openServiceRegistry"},
} {
t.Run(tc.function, func(t *testing.T) {
diff --git a/internal/host/rootgeneration_inventory.go b/internal/host/rootgeneration_inventory.go
index d873e930f..4fd3c9224 100644
--- a/internal/host/rootgeneration_inventory.go
+++ b/internal/host/rootgeneration_inventory.go
@@ -238,7 +238,11 @@ func productionGenerationInventory(ctx context.Context) (generationInventory, er
if err != nil {
return generationInventory{}, err
}
- return generationRequiredInventory(ctx, store, snapshot)
+ snapshot, err = generationRequiredInventory(ctx, store, snapshot)
+ if err != nil {
+ return generationInventory{}, err
+ }
+ return productionGenerationRegistryInventory(ctx, snapshot)
}
func generationInventoryAt(ctx context.Context, censusPath, fencesPath, journalPath string) (generationInventory, error) {
diff --git a/internal/host/rootgeneration_rows.go b/internal/host/rootgeneration_rows.go
new file mode 100644
index 000000000..93996acd3
--- /dev/null
+++ b/internal/host/rootgeneration_rows.go
@@ -0,0 +1,100 @@
+package host
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// Registry rows can contradict retained obligations, but cannot remove them.
+// Read them through the service-user parser, including on the final recheck.
+func productionGenerationRegistryInventory(ctx context.Context, snapshot generationInventory) (generationInventory, error) {
+ reg, err := openServiceRegistryReadOnly(filepath.Join(dataDir(), "registry.db"))
+ if err != nil {
+ return generationInventory{}, err
+ }
+ defer reg.Close()
+ return generationRegistryInventory(ctx, reg, snapshot)
+}
+
+func generationRegistryInventory(ctx context.Context, reg *registry.Registry, snapshot generationInventory) (generationInventory, error) {
+ var zero generationInventory
+ if reg == nil {
+ return zero, os.ErrPermission
+ }
+ type key struct{ kind, host, name string }
+ expected := map[key]registry.ClaimOwnership{}
+ hosts := map[string]bool{}
+ for _, p := range snapshot.Payloads {
+ in := p.Inventory
+ hosts[in.Host] = true
+ for _, entry := range in.Entries {
+ if entry.Kind != "route" && entry.Kind != "timer" {
+ continue
+ }
+ name := ""
+ if entry.Kind == "timer" {
+ name = strings.TrimPrefix(entry.RelativePath, in.Host+"/")
+ if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\") || entry.RelativePath != in.Host+"/"+name {
+ return zero, os.ErrPermission
+ }
+ } else if entry.RelativePath != in.Host {
+ return zero, os.ErrPermission
+ }
+ own := registry.ClaimOwnership{ClaimIncarnation: in.Incarnation, ClaimStamp: in.InstallationStamp, ClaimOwner: entry.Ownership, ClaimRowID: entry.RowIdentity}
+ k := key{entry.Kind, in.Host, name}
+ if !own.Valid() || entry.ManagedRoot != "registry" || entry.Materialization != "materialized" {
+ return zero, os.ErrPermission
+ }
+ if _, exists := expected[k]; exists {
+ return zero, os.ErrPermission
+ }
+ expected[k] = own
+ if len(expected) > 8192 {
+ return zero, os.ErrPermission
+ }
+ }
+ }
+ for host := range hosts {
+ if err := ctx.Err(); err != nil {
+ return zero, err
+ }
+ route, err := reg.Get(host)
+ if err != nil {
+ return zero, err
+ }
+ k := key{"route", host, ""}
+ own, exists := expected[k]
+ if (route != nil) != exists {
+ return zero, os.ErrPermission
+ }
+ if exists {
+ if route.Host != host || route.ClaimOwnership != own {
+ return zero, os.ErrPermission
+ }
+ delete(expected, k)
+ }
+ timers, err := reg.TimersFor(host)
+ if err != nil {
+ return zero, err
+ }
+ if len(timers) > 8192 {
+ return zero, os.ErrPermission
+ }
+ for _, timer := range timers {
+ k := key{"timer", host, timer.Name}
+ own, exists := expected[k]
+ if !exists || timer.Host != host || timer.ClaimOwnership != own || timer.Registration != own.ClaimRowID {
+ return zero, os.ErrPermission
+ }
+ delete(expected, k)
+ }
+ }
+ if len(expected) != 0 {
+ return zero, os.ErrPermission
+ }
+ return snapshot, ctx.Err()
+}
diff --git a/internal/provision/testdata/plan14-root-reads-e.tsv b/internal/provision/testdata/plan14-root-reads-e.tsv
index b88ae72bc..0b891395e 100644
--- a/internal/provision/testdata/plan14-root-reads-e.tsv
+++ b/internal/provision/testdata/plan14-root-reads-e.tsv
@@ -54,7 +54,7 @@ internal/release/roothistory_replace.go replacementReconstruction authority E1 S
internal/release/signedhead.go StageSignedHead authority E1 Canonical route is supplied by the root unit verifier. Verifies signed channel, digest ratchet and manifest/artifact before staging; unavailable pin census never authorizes restaging differing bytes. Fixtures: TestPlan14Incident20261006WithLedger and TestPlan14SignedNamedRecoveryAuthority. registry:Account@202,registry:Account@302,registry:Account@403,registry:App@214,registry:App@415,registry:Channel@222,registry:Host@434 ca02f04e55556ba0233dfa0848b0c533a9d9aa06cad6abd33cfd2968b3bd838a
internal/rootstate/link_linux.go ReadTrustedLink authority A,E1 Checks the full root-owned parent chain before descriptor-based live-link reading. Fixture: TestPlan14TrustedLiveLink; privileged acceptance remains required. file:Open@156 90f8e8de4e2c71f07b5d749cbe4bb1c1dfa426576a82138823449f56fc9ec5ab
internal/rootstate/link_linux.go directory.readLink authority A,E1 Pins the symlink inode with O_PATH and checks owner/type/link count before readlinkat. Fixture: TestPlan14TrustedLiveLink. file:Fstat@356,file:Openat@183,file:Readlinkat@554 a8559fe63dce48830916004ecfe7fbbe5d419568c7daf7a3073cf1b5bb12c3d7
-@coverage internal/host 5feaa03212de11e8c12148f2d490885e2a5559e803691d3fb03a1763ea858ebb
+@coverage internal/host 31f9cc2f83dbad8a45e0bc453b80bb33d85f2e4187ce155c7a56afa69b7ce5cd
@coverage internal/release 2bb546a86c1ab49b02e424eb0b366b46776857a1249baa2d6fb61ead4209d6bd
@coverage internal/rootstate 6338f74a6b2059cf77cf478bd6111b37b441eb9d65a309464c378359d4bdac05
internal/release/roothistory_status.go RootHistory.MigrationStatus authority E1 Exact completion binds the accepted authorization digest from durable private history to enrolled-root signed readback. Pending originals, changed evidence and canonical sources cannot supply retirement completion. Fixtures: TestPlan14HistoryReconstructionCompletion, TestPlan14HistoryRetirementCompletion, TestPlan14ReadReconstructionCompletion, TestPlan14ReadRetirementCompletion, TestPlan14RecoveryRetirementProductionCallback, TestPlan14RootHistoryReconstructionCrash, TestPlan14HistoryRetirementProcessCrash. file:Read@1062,file:Read@3891 f0ab7ad2d2d70f8c22ebfd575f5a1c6931f7eba30ccdba80ad43d0efffc88154
@@ -303,7 +303,7 @@ internal/host/rootgeneration_inventory.go generationInventoryDigest authority E2
internal/host/rootgeneration_inventory.go generationOwner authority E2 E24: independent root installation and launch enumeration, exact reservation reconciliation and fresh quiescent whole-version-tree kernel census; missing sources, open unreserved launches, foreign namespaces and uncontained tasks refuse. Production pin source certifies this evidence before returning pins. Fixtures: TestPlan14GenerationInventoryCompleteness, TestPlan14GenerationInventoryPinConsumer, TestPlan14GenerationInventoryLifecycle, TestPlan14GenerationInventoryProcessCrash, TestPlan14GenerationKernelCensusUnknownReference, TestPlan14GenerationLaunchInventory, TestPlan14GenerationReaderDoesNotInitialize, TestPlan14GenerationInstallationInventory. Checked production source/kernel witnesses passed on the E27 root runner. E29 physical-payload and installed-unit witnesses passed on the controller runner; E30 required-artifact witness pending. 69f89153837761f13116937b330ffbf406891299d5580296ea4aade905aa02ce
internal/host/rootgeneration_inventory.go generationReconciler.reconcile authority E2 E30: derive route and ordinary-instance state-container requirements from schema- and shape-validated canonical deployment records bound to retained installation incarnation; include record contents and ETag in both source reads and the certificate digest. Missing, mismatched, omitted or changed evidence refuses without retiring pins. Fixtures: TestPlan14GenerationRequiredArtifacts, TestPlan14GenerationProductionModeGate. Production fixed-path witness TestPlan14GenerationRequiredArtifactsRootRunner is runner-pending. Timer classes, parked artifacts, positive empty-installation evidence and running-tenant containment remain incomplete. d928288910227cc14f158595e44d052436a23c407e89682dd0930ee1011d7e57
internal/host/rootgeneration_inventory.go openGenerationLaunchInventory authority E2 E24: independent root installation and launch enumeration, exact reservation reconciliation and fresh quiescent whole-version-tree kernel census; missing sources, open unreserved launches, foreign namespaces and uncontained tasks refuse. Production pin source certifies this evidence before returning pins. Fixtures: TestPlan14GenerationInventoryCompleteness, TestPlan14GenerationInventoryPinConsumer, TestPlan14GenerationInventoryLifecycle, TestPlan14GenerationInventoryProcessCrash, TestPlan14GenerationKernelCensusUnknownReference, TestPlan14GenerationLaunchInventory, TestPlan14GenerationReaderDoesNotInitialize, TestPlan14GenerationInstallationInventory. Checked production source/kernel witnesses passed on the E27 root runner. E29 physical-payload and installed-unit witnesses passed on the controller runner; E30 required-artifact witness pending. 522445e6d8acbe083d9d8f001719c4dbd3af57c56429e6b73b05c55a3826cb8b
-internal/host/rootgeneration_inventory.go productionGenerationInventory authority E2 E30: derive route and ordinary-instance state-container requirements from schema- and shape-validated canonical deployment records bound to retained installation incarnation; include record contents and ETag in both source reads and the certificate digest. Missing, mismatched, omitted or changed evidence refuses without retiring pins. Fixtures: TestPlan14GenerationRequiredArtifacts, TestPlan14GenerationProductionModeGate. Production fixed-path witness TestPlan14GenerationRequiredArtifactsRootRunner is runner-pending. Timer classes, parked artifacts, positive empty-installation evidence and running-tenant containment remain incomplete. 5312e59ec04dc9ce0b19a6072b95c212f6e3dd0419e9118d78c6e915171c8c65
+internal/host/rootgeneration_inventory.go productionGenerationInventory authority E2 E31: census-only generation completeness compares retained route/timer obligations with actual ownership, row identity and timer registration through the existing read-only service-user parser on both source reads. Missing, replaced, extra or unreadable rows invalidate completeness without releasing pins. Row agreement does not establish omitted artifact classes or empty-installation authority. Fixtures: TestPlan14GenerationRegistryRows, TestPlan14GenerationRegistryReadOnly, TestPlan14GenerationRegistryProductionReader. Extended TestPlan14GenerationRequiredArtifactsRootRunner is runner-pending. 7eda05c4021b20a3c9aa102a291adac54ca2767484a3b21da7ef471d960e94ba
internal/host/rootgeneration_inventory.go type generationInventory authority E2 E30: derive route and ordinary-instance state-container requirements from schema- and shape-validated canonical deployment records bound to retained installation incarnation; include record contents and ETag in both source reads and the certificate digest. Missing, mismatched, omitted or changed evidence refuses without retiring pins. Fixtures: TestPlan14GenerationRequiredArtifacts, TestPlan14GenerationProductionModeGate. Production fixed-path witness TestPlan14GenerationRequiredArtifactsRootRunner is runner-pending. Timer classes, parked artifacts, positive empty-installation evidence and running-tenant containment remain incomplete. 4de1dc2a2c9261b1e7bbd811d4d78a271d2fc5a37aeca196177f3e9527d0408e
internal/host/rootgeneration_inventory.go type generationReconciler authority E2 E24: independent root installation and launch enumeration, exact reservation reconciliation and fresh quiescent whole-version-tree kernel census; missing sources, open unreserved launches, foreign namespaces and uncontained tasks refuse. Production pin source certifies this evidence before returning pins. Fixtures: TestPlan14GenerationInventoryCompleteness, TestPlan14GenerationInventoryPinConsumer, TestPlan14GenerationInventoryLifecycle, TestPlan14GenerationInventoryProcessCrash, TestPlan14GenerationKernelCensusUnknownReference, TestPlan14GenerationLaunchInventory, TestPlan14GenerationReaderDoesNotInitialize, TestPlan14GenerationInstallationInventory. Checked production source/kernel witnesses passed on the E27 root runner. E29 physical-payload and installed-unit witnesses passed on the controller runner; E30 required-artifact witness pending. a979cdff1cd6311cf57d4a93e4501fb8dc2f65772d0013e622175b2a1f610d91
internal/hostguard/generationinventory.go RootAuthority.GenerationInstallations authority E2 E24: independent root installation and launch enumeration, exact reservation reconciliation and fresh quiescent whole-version-tree kernel census; missing sources, open unreserved launches, foreign namespaces and uncontained tasks refuse. Production pin source certifies this evidence before returning pins. Fixtures: TestPlan14GenerationInventoryCompleteness, TestPlan14GenerationInventoryPinConsumer, TestPlan14GenerationInventoryLifecycle, TestPlan14GenerationInventoryProcessCrash, TestPlan14GenerationKernelCensusUnknownReference, TestPlan14GenerationLaunchInventory, TestPlan14GenerationReaderDoesNotInitialize, TestPlan14GenerationInstallationInventory. Checked production source/kernel witnesses passed on the E27 root runner. E29 physical-payload and installed-unit witnesses passed on the controller runner; E30 required-artifact witness pending. 593768e6ba937cf2cfa2635c8f796f767d6c2354737a72073cc351f514faed20
@@ -317,3 +317,6 @@ internal/host/rootgeneration_payload_linux.go generationPayloadFiles authority E
internal/host/rootgeneration_payload_other.go generationPayloadFiles authority E2 E28: retained data payloads require exact device/inode/type through no-symlink descriptor opens before and after kernel inspection. Missing, replaced and intended payloads remain unresolved. Tests: TestPlan14GenerationPayloadPhysicalIdentity; privileged TestPlan14GenerationPayloadPhysicalRootRunner remains runner-pending. Route/timer and running-tenant containment are not accepted by this check. candidate:Kind@183,candidate:Kind@204 182189d8409113d671cdc76fc5d2d73e7056057bca230d546e65300c428b2c18
internal/host/rootgeneration_requirements.go generationRequiredInventory authority E2 E30: derive route and ordinary-instance state-container requirements from schema- and shape-validated canonical deployment records bound to retained installation incarnation; include record contents and ETag in both source reads and the certificate digest. Missing, mismatched, omitted or changed evidence refuses without retiring pins. Fixtures: TestPlan14GenerationRequiredArtifacts, TestPlan14GenerationProductionModeGate. Production fixed-path witness TestPlan14GenerationRequiredArtifactsRootRunner is runner-pending. Timer classes, parked artifacts, positive empty-installation evidence and running-tenant containment remain incomplete. aaa70feaad4ed0331e7d45433bb0b6476601bbed68783294215cce16eaa5a3eb
internal/host/rootgeneration_requirements.go type generationRecord authority E2 E30: derive route and ordinary-instance state-container requirements from schema- and shape-validated canonical deployment records bound to retained installation incarnation; include record contents and ETag in both source reads and the certificate digest. Missing, mismatched, omitted or changed evidence refuses without retiring pins. Fixtures: TestPlan14GenerationRequiredArtifacts, TestPlan14GenerationProductionModeGate. Production fixed-path witness TestPlan14GenerationRequiredArtifactsRootRunner is runner-pending. Timer classes, parked artifacts, positive empty-installation evidence and running-tenant containment remain incomplete. ff50bc959af62afbd73b01e9640346f9c79d6b6578e03cb3a7d6bdff4d90fc28
+internal/host/rootgeneration_rows.go generationRegistryInventory authority E2 E31: census-only generation completeness compares retained route/timer obligations with actual ownership, row identity and timer registration through the existing read-only service-user parser on both source reads. Missing, replaced, extra or unreadable rows invalidate completeness without releasing pins. Row agreement does not establish omitted artifact classes or empty-installation authority. Fixtures: TestPlan14GenerationRegistryRows, TestPlan14GenerationRegistryReadOnly, TestPlan14GenerationRegistryProductionReader. Extended TestPlan14GenerationRequiredArtifactsRootRunner is runner-pending. registry:ClaimOwnership@1738,registry:ClaimOwnership@2117,registry:ClaimRowID@2170,registry:Host@1716,registry:Host@2095,registry:Name@2036,registry:Registration@2148,whole:ClaimOwnership@1252,whole:ClaimOwnership@1316,whole:ClaimOwnership@1330,whole:ClaimOwnership@1344,whole:ClaimOwnership@1615,whole:ClaimOwnership@1744,whole:ClaimOwnership@1762,whole:ClaimOwnership@1817,whole:ClaimOwnership@2066,whole:ClaimOwnership@2123,whole:ClaimOwnership@2141,whole:ClaimOwnership@2236,whole:ClaimOwnership@2264,whole:Route@1633,whole:Timer@1922,whole:Timer@2000 4f33a3cfa22218a9faecf3ad06b86ad7832a4b1f98c484cff79d948e8c7eb7b4
+@coverage internal/registry c5d195f3b4e018a076aee1d2a139c407a9bdcbc43896f95660d3437667764fee
+internal/registry/remote.go serveRemote authority E2 E31 mixed declaration: E2 adds only TimersFor to the existing read-only child allowlist so root can compare actual timer identities with retained obligations without parsing SQLite as root. All mutations remain refused. Generic writable dispatch remains unchanged legacy behavior. Pre-flight 80, 82, 83 and 90 justify promotion while retaining the original immutable legacy fingerprint. Fixtures: TestRemoteReadOnlyWALRegistry; production credential-drop witness TestPlan14GenerationRequiredArtifactsRootRunner is runner-pending. file:Open@332,registry:CallbackError@1681,registry:CallbackFailed@1638,registry:Deadline@1264,registry:Deadline@1338,registry:Method@1022,registry:Method@1043,registry:Method@1573,whole:remoteRequest@1395,whole:remoteRequest@835,whole:remoteRequest@922,whole:remoteResponse@1117,whole:remoteResponse@1163,whole:remoteResponse@1457,whole:remoteResponse@1755,whole:remoteResponse@1791,whole:remoteResponse@392,whole:remoteResponse@431 0d083f04f3363b5df2aa9df4e05fe80014b2d07ec77c88fd449807eb170deb93
diff --git a/internal/registry/readonly_test.go b/internal/registry/readonly_test.go
index 89ac7b0a6..c0fd0477b 100644
--- a/internal/registry/readonly_test.go
+++ b/internal/registry/readonly_test.go
@@ -11,6 +11,8 @@ func TestRemoteReadOnlyWALRegistry(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("read-only permission fixture requires a nonroot uid")
}
+ t.Setenv("CARLOS_HOST_CLAIM", "true")
+ owner := ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 3, ClaimOwner: "owner", ClaimRowID: "route"}
dir := t.TempDir()
path := filepath.Join(dir, "registry.db")
writer, err := Open(path)
@@ -18,7 +20,11 @@ func TestRemoteReadOnlyWALRegistry(t *testing.T) {
t.Fatal(err)
}
defer writer.Close()
- if err := writer.Upsert(Route{Host: "readonly", Kind: "instance", Account: "acct", App: "app"}); err != nil {
+ if err := writer.Upsert(Route{Host: "readonly", Kind: "instance", Account: "acct", App: "app", ClaimOwnership: owner}); err != nil {
+ t.Fatal(err)
+ }
+ timer, err := writer.UpsertTimerRegistration(Timer{Host: "readonly", Name: "backup", Kind: "cron", Spec: "* * * * *"}, "")
+ if err != nil {
t.Fatal(err)
}
for _, suffix := range []string{"", "-wal", "-shm"} {
@@ -53,6 +59,20 @@ func TestRemoteReadOnlyWALRegistry(t *testing.T) {
if err != nil || row == nil || row.Account != "acct" || row.App != "app" {
t.Fatalf("readonly WAL read: %+v %v", row, err)
}
+ timers, err := reader.TimersFor("readonly")
+ if err != nil || len(timers) != 1 || timers[0].Registration != timer.Registration || timers[0].ClaimOwnership != timer.ClaimOwnership || timers[0].ClaimOwner != owner.ClaimOwner || timers[0].ClaimStamp != owner.ClaimStamp {
+ t.Fatalf("readonly timer identity: %+v %v", timers, err)
+ }
+ if err := reader.ReplaceTimers("readonly", nil); err == nil {
+ t.Fatal("read-only worker replaced timers")
+ }
+ if err := reader.DeleteTimer("readonly", "backup"); err == nil {
+ t.Fatal("read-only worker deleted timer")
+ }
+ after, err := reader.TimersFor("readonly")
+ if err != nil || len(after) != 1 || after[0] != timers[0] {
+ t.Fatalf("refused writes changed timer: %+v %v", after, err)
+ }
if err := reader.Upsert(Route{Host: "forbidden", Kind: "instance"}); err == nil {
t.Fatal("read-only worker accepted mutation")
}
diff --git a/internal/registry/remote.go b/internal/registry/remote.go
index 07707597e..ea5197302 100644
--- a/internal/registry/remote.go
+++ b/internal/registry/remote.go
@@ -275,7 +275,7 @@ func serveRemote(path string, input io.Reader, output io.Writer, readOnly bool)
}
}()
for q := range requests {
- if readOnly && q.Method != "Get" {
+ if readOnly && q.Method != "Get" && q.Method != "TimersFor" {
out := remoteResponse{}
setResponseError(&out, errRemoteMethod)
if err := enc.Encode(out); err != nil {