carlos / platform Public

WIP files seed checkpoint 11 (not for merge)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 211445fecb991b8819b95539f54054fac5df3c36 parent 44659fe
15 files changed, +1005 −121
  • internal/host/claimfilesseed_restore_linux_test.go +5 −6
  • internal/host/claimhandover_recovery_test.go +29 −1
  • internal/host/claimhandover_transport.go +36 −3
  • internal/host/claimretire_transport.go +4 −1
  • internal/host/claimservinghandover.go +139 −0
  • internal/host/claimservinghandover_linux_test.go +216 −73
  • internal/host/claimservinglegacyroot_linux.go +1 −1
  • internal/host/claimservingpark_transport_linux_test.go +10 −2
  • internal/host/claimservingparkroot_linux.go +1 −2
  • internal/host/claimservingpeer_rootrunner_linux_test.go +40 −2
  • internal/hostbox/coldhandover.go +25 −20
  • internal/hostbox/servinghandover.go +288 −0
  • internal/hostbox/servinghandover_linux_test.go +167 −0
  • internal/hostguard/coldhandover.go +19 −2
  • internal/provision/testdata/plan14-root-reads-seed.tsv +25 −8

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	361.476s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.274s
ok  	github.com/carlosframework/platform/cmd/moveprobe	93.231s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.711s
ok  	github.com/carlosframework/platform/internal/activator	233.953s
ok  	github.com/carlosframework/platform/internal/agentsock	0.015s
ok  	github.com/carlosframework/platform/internal/alerting	0.004s
ok  	github.com/carlosframework/platform/internal/analytics	0.019s
ok  	github.com/carlosframework/platform/internal/appconfig	2.335s
ok  	github.com/carlosframework/platform/internal/apperr	0.073s
ok  	github.com/carlosframework/platform/internal/applog	0.137s
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.648s
?   	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.012s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.717s
ok  	github.com/carlosframework/platform/internal/changelog	0.305s
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	369.281s
?   	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.147s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.005s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.005s
ok  	github.com/carlosframework/platform/internal/docsite	0.004s
ok  	github.com/carlosframework/platform/internal/domains	2.147s
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.311s
?   	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.126s
ok  	github.com/carlosframework/platform/internal/gate	1.655s
ok  	github.com/carlosframework/platform/internal/genselect	0.016s
ok  	github.com/carlosframework/platform/internal/geo	0.019s
ok  	github.com/carlosframework/platform/internal/geoip	0.029s
ok  	github.com/carlosframework/platform/internal/host	150.038s
ok  	github.com/carlosframework/platform/internal/hostactors	0.477s
ok  	github.com/carlosframework/platform/internal/hostbox	2.340s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.556s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.018s
ok  	github.com/carlosframework/platform/internal/hostexec	5.026s
ok  	github.com/carlosframework/platform/internal/hostfence	0.045s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.548s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.018s
ok  	github.com/carlosframework/platform/internal/hostguard	1.010s
ok  	github.com/carlosframework/platform/internal/hostops	1.512s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.017s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.017s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.493s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.136s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.017s
ok  	github.com/carlosframework/platform/internal/infraview	0.016s
ok  	github.com/carlosframework/platform/internal/instancectl	1.830s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.748s
ok  	github.com/carlosframework/platform/internal/instancefs	0.022s
ok  	github.com/carlosframework/platform/internal/instancekey	0.018s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.024s
ok  	github.com/carlosframework/platform/internal/layout	1.482s
ok  	github.com/carlosframework/platform/internal/lease	9.473s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.022s
ok  	github.com/carlosframework/platform/internal/ledger	0.014s
ok  	github.com/carlosframework/platform/internal/mail	0.166s
ok  	github.com/carlosframework/platform/internal/maintenance	0.091s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.575s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.075s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	21.128s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.796s
ok  	github.com/carlosframework/platform/internal/objstore	0.176s
ok  	github.com/carlosframework/platform/internal/ops	0.173s
ok  	github.com/carlosframework/platform/internal/park	0.210s
ok  	github.com/carlosframework/platform/internal/privatefile	0.007s
ok  	github.com/carlosframework/platform/internal/provision	13.104s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.010s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	4.127s
ok  	github.com/carlosframework/platform/internal/relay	7.222s
ok  	github.com/carlosframework/platform/internal/release	19.775s
ok  	github.com/carlosframework/platform/internal/rollout	3.819s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.005s
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.026s
ok  	github.com/carlosframework/platform/internal/s3compat	0.006s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.321s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	10.189s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.025s
ok  	github.com/carlosframework/platform/internal/status	0.044s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.222s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.008s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.005s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.579s
=== 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.026s
=== RUN   TestT4StorageProbeMinio
2026/10/14 18:36:57 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE793FEF644036, 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.024s
minio contract: PASS
diff --git a/internal/host/claimfilesseed_restore_linux_test.go b/internal/host/claimfilesseed_restore_linux_test.go
index 43b70d92d..617175f38 100644
--- a/internal/host/claimfilesseed_restore_linux_test.go
+++ b/internal/host/claimfilesseed_restore_linux_test.go
@@ -33,7 +33,7 @@ import (
// This fixture ends at Restore: a legacy holder has no C8 evidence yet.
// It uses the real selector, lease against local S3, retry wrapper, admission,
// activation, prelaunch journal, selected Work, stop coordinator and retirement
-// transport. Kernel checks, installation enrollment and launch preflight are
+// transport. Initial installation, kernel checks and launch preflight are
// explicit seams; it does not claim an installed-unit or privileged witness.
type servingPreparation struct {
f *servingRootFixture
@@ -60,12 +60,11 @@ func newServingPreparation(t *testing.T, f *servingRootFixture, boot, controller
producers := &ClaimProducerSet{}
w := &ClaimWakeAssembly{LeaseRegion: "eu-west-1", Manager: new(cgroup.Manager), Inputs: p.owners, RequireServingEnrollment: true}
p.wake = w
- w.AfterAdmission = func(ctx context.Context, _, admitted hostclaim.Observed) error {
+ root := servingHandoverRoot(t, f)
+ hook := coldAdmissionHook(f.f.guard, handoverRecoveryIdentity(t, f.actor), servingRootSocket(t, root))
+ w.AfterAdmission = func(ctx context.Context, prior, admitted hostclaim.Observed) error {
p.events = append(p.events, "admit")
- // The production root installation door requires privileged host inventory.
- // Retain that explicit seam, but do not advance it before admission.
- f.install(admitted)
- return nil
+ return hook(ctx, prior, admitted)
}
w.Prelaunch = func(ctx context.Context, o hostclaim.Observed, work hostwrite.Work, in ClaimLaunchInputs) (*activator.RoleLaunch, error) {
p.events = append(p.events, "prelaunch")
diff --git a/internal/host/claimhandover_recovery_test.go b/internal/host/claimhandover_recovery_test.go
index f1959faca..c2e52e469 100644
--- a/internal/host/claimhandover_recovery_test.go
+++ b/internal/host/claimhandover_recovery_test.go
@@ -24,6 +24,7 @@ import (
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
)
@@ -98,7 +99,7 @@ func TestClaimColdHandoverAfterControllerRecoveryReachesNextWake(t *testing.T) {
admitted := former.admit(t, f)
f.revision = 8
restarted := f.controller(boot, "controller-2", 4202, "start-2")
- route := registry.Route{Host: recoveryHost, Kind: "instance", ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: admitted.Claim.Holder.Incarnation, ClaimStamp: admitted.Claim.Holder.Stamp, ClaimOwner: "old-installation", ClaimRowID: "old-route"}}
+ route := registry.Route{Host: recoveryHost, Kind: "instance", Account: admitted.Claim.Holder.Account, App: admitted.Claim.Holder.App, DBPath: provision.NestedDBPath(f.guard.DataDir, recoveryHost), Socket: provision.NestedSocket(recoveryHost), ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: admitted.Claim.Holder.Incarnation, ClaimStamp: admitted.Claim.Holder.Stamp, ClaimOwner: "old-installation", ClaimRowID: "old-route"}}
f.kernel.alive = true
if err := restarted.runtime.Prerequisites(ctx, route, f.read(), recoveryPolicy); !errors.Is(err, ErrClaimStopControllerAlive) {
t.Fatalf("live predecessor accepted: %v", err)
@@ -134,6 +135,27 @@ func TestClaimColdHandoverAfterControllerRecoveryReachesNextWake(t *testing.T) {
}
owner := route.ClaimOwnership
in := hostbox.StopInventory{Complete: true, Host: route.Host, Incarnation: owner.ClaimIncarnation, InstallationStamp: owner.ClaimStamp, Box: "box-1", Enrollment: "enrollment-1", DirectoryRevision: "7", Entries: []hostbox.StopInventoryEntry{{Kind: "route", ArtifactID: owner.ClaimRowID, ManagedRoot: "registry", RelativePath: route.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"}}}
+ if err = os.MkdirAll(filepath.Dir(route.DBPath), 0700); err != nil {
+ t.Fatal(err)
+ }
+ rel, err := filepath.Rel(f.guard.DataDir, filepath.Dir(route.DBPath))
+ if err != nil {
+ t.Fatal(err)
+ }
+ roots := map[string]string{"data": f.guard.DataDir}
+ a, err := (hostbox.TypedParker{Roots: roots, ParkedRoots: roots, Incarnation: owner.ClaimIncarnation, Host: route.Host, Retirement: "fixture"}).CaptureArtifact("data", rel, owner.ClaimOwner, hostbox.ArtifactPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ in.Entries = append(in.Entries, hostbox.StopInventoryEntry{Kind: "path", ArtifactID: "state", ManagedRoot: "data", RelativePath: rel, Ownership: owner.ClaimOwner, Materialization: "materialized", Device: a.Dev, Inode: a.Ino, Type: a.Type})
+ holder := prior.Claim.Holder
+ body, err := json.Marshal(provision.Record{Schema: 1, Host: prior.Host, Account: holder.Account, App: holder.App, CreatedAt: holder.CreatedAt, Channel: "edge"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = f.claims.Put(ctx, holder.Key, body, ""); err != nil {
+ t.Fatal(err)
+ }
if err = inventory.WriteStopInventory(ctx, in); err != nil {
t.Fatal(err)
}
@@ -162,6 +184,12 @@ func TestClaimColdHandoverAfterControllerRecoveryReachesNextWake(t *testing.T) {
coordinator := f.root(recoveryStopDrain{})
coordinator.Guard = rootGuard
root := ClaimRootHTTP{Launches: f.journal, Stop: ClaimStopHTTP{Coordinator: coordinator, Authenticate: func(context.Context, net.Conn) (StopPeer, error) { return f.peer, nil }}, Roots: map[string]string{"data": f.guard.DataDir}, Reconcile: hostbox.ReconcileAdapter{Box: "box-1", Enrollment: "enrollment-1", Registry: reg, Inventory: inventory, Retirement: hostbox.TypedRetirement{JournalDir: filepath.Join(base, "retire")}}}
+ root.RequireNewWork = func(context.Context) error { return nil }
+ root.InventoryActor = func(context.Context) (hostbox.Actor, error) {
+ actor := restarted.local.Actor
+ actor.Eligible = true
+ return actor, nil
+ }
socket := startRetirementRecoveryTransport(t, root)
unauthenticated := root
unauthenticated.Stop.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return StopPeer{}, errors.New("unknown requester") }
diff --git a/internal/host/claimhandover_transport.go b/internal/host/claimhandover_transport.go
index cf0f414cc..7d1e6d8f6 100644
--- a/internal/host/claimhandover_transport.go
+++ b/internal/host/claimhandover_transport.go
@@ -14,6 +14,7 @@ import (
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/instancectl"
)
type coldHandoverRequest struct {
@@ -29,7 +30,15 @@ func (h ClaimRootHTTP) serveColdHandover(w http.ResponseWriter, r *http.Request,
w.WriteHeader(http.StatusBadRequest)
return
}
- if request.Prior.BootID != peer.BootID || request.Prior.ControllerID != peer.ControllerID {
+ if request.Prior.Session == 0 && request.Prior.Op == "" && request.Prior.Phase == "" {
+ prior, err := h.servingLegacyHandoverTarget(r.Context(), request)
+ if err != nil {
+ w.WriteHeader(http.StatusConflict)
+ return
+ }
+ request.Prior = prior
+ }
+ if request.Prior.Session != 0 && (request.Prior.BootID != peer.BootID || request.Prior.ControllerID != peer.ControllerID) {
// ServeHTTP authenticates the live requester independently. Recovery
// preserves the predecessor's recorded tuple; only its exact completed
// root receipt grants cleanup authority, never the requester's tuple.
@@ -43,8 +52,25 @@ func (h ClaimRootHTTP) serveColdHandover(w http.ResponseWriter, r *http.Request,
w.WriteHeader(http.StatusServiceUnavailable)
return
}
- handover := hostbox.ColdHandover{Guard: h.Stop.Coordinator.Guard, Claims: h.Stop.Coordinator.Evidence, Registry: h.Reconcile.Registry, Inventory: h.Reconcile.Inventory, Launches: h.Launches, Roots: h.Roots, JournalDir: filepath.Join(h.Reconcile.Retirement.JournalDir, "cold"), VerifyReceipt: verifyRetirementReceipt}
- if err := handover.Run(r.Context(), request.Prior, request.Next); err != nil {
+ handover := hostbox.ColdHandover{OperationsOnly: true, Guard: h.Stop.Coordinator.Guard, Claims: h.Stop.Coordinator.Evidence, Registry: h.Reconcile.Registry, Inventory: h.Reconcile.Inventory, Launches: h.Launches, Roots: h.Roots, JournalDir: filepath.Join(h.Reconcile.Retirement.JournalDir, "cold"), VerifyReceipt: verifyRetirementReceipt}
+ var err error
+ if request.Next.Op == "" && request.Next.Phase == "" {
+ serving := h.servingHandover()
+ serving.Validate = func(ctx context.Context, c *hostclaim.Claim, p hostguard.FenceTarget, n hostbox.ColdSuccessor, in hostbox.StopInventory, installation hostguard.RootInstallation) error {
+ fresh, err := h.Stop.Authenticate(ctx, instancectl.RequestConn(r.Context()))
+ if err != nil {
+ return err
+ }
+ if fresh != peer {
+ return ErrClaimStopIdentity
+ }
+ return h.validateServingHandover(ctx, c, p, n, in, installation)
+ }
+ err = serving.Run(r.Context(), request.Prior, request.Next)
+ } else {
+ err = handover.Run(r.Context(), request.Prior, request.Next)
+ }
+ if err != nil {
w.WriteHeader(http.StatusConflict)
return
}
@@ -53,6 +79,13 @@ func (h ClaimRootHTTP) serveColdHandover(w http.ResponseWriter, r *http.Request,
func coldAdmissionHook(guard hostguard.Guard, identity hostbox.IdentityProvider, socket string) func(context.Context, hostclaim.Observed, hostclaim.Observed) error {
return coldHandoverWith(identity, socket, func(ctx context.Context, prior hostclaim.Observed) (hostguard.FenceReceipt, error) {
old := prior.Claim.Holder
+ if old.Session == 0 && prior.Claim.Op.Kind == hostclaim.KindServing && prior.Claim.Op.ID == "" && prior.Claim.Op.Phase == "" {
+ actor, err := identity.Verify(ctx)
+ if err != nil {
+ return hostguard.FenceReceipt{}, err
+ }
+ return hostguard.FenceReceipt{Target: hostguard.FenceTarget{Host: prior.Host, Incarnation: old.Incarnation, Stamp: old.Stamp, Box: actor.Box, Enrollment: actor.Enrollment}}, nil
+ }
return guard.CompletedColdPredecessor(ctx, prior.Host, hostguard.Captured{Incarnation: old.Incarnation, Stamp: old.Stamp}, old.Session, prior.Claim.Op.ID, prior.Claim.Op.Phase)
})
}
diff --git a/internal/host/claimretire_transport.go b/internal/host/claimretire_transport.go
index 3495b03bd..9b9f6ff88 100644
--- a/internal/host/claimretire_transport.go
+++ b/internal/host/claimretire_transport.go
@@ -39,7 +39,10 @@ func (h ClaimRootHTTP) RecoverPending(ctx context.Context) (recoveryErr error) {
// startup recovery failed. Attempt them before the service accepts work.
defer func() { recoveryErr = errors.Join(recoveryErr, h.servingParks.recoverPending(ctx)) }()
- cold := hostbox.ColdHandover{Guard: h.Stop.Coordinator.Guard, Claims: h.Stop.Coordinator.Evidence, Registry: h.Reconcile.Registry, Inventory: h.Reconcile.Inventory, Launches: h.Launches, Roots: h.Roots, JournalDir: filepath.Join(h.Reconcile.Retirement.JournalDir, "cold"), VerifyReceipt: verifyRetirementReceipt}
+ if err := h.servingHandover().Recover(ctx); err != nil {
+ return err
+ }
+ cold := hostbox.ColdHandover{OperationsOnly: true, Guard: h.Stop.Coordinator.Guard, Claims: h.Stop.Coordinator.Evidence, Registry: h.Reconcile.Registry, Inventory: h.Reconcile.Inventory, Launches: h.Launches, Roots: h.Roots, JournalDir: filepath.Join(h.Reconcile.Retirement.JournalDir, "cold"), VerifyReceipt: verifyRetirementReceipt}
if err := cold.Recover(ctx); err != nil {
return err
}
diff --git a/internal/host/claimservinghandover.go b/internal/host/claimservinghandover.go
new file mode 100644
index 000000000..35f663518
--- /dev/null
+++ b/internal/host/claimservinghandover.go
@@ -0,0 +1,139 @@
+package host
+
+import (
+ "context"
+ "path/filepath"
+ "reflect"
+ "strconv"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+)
+
+func (h ClaimRootHTTP) servingHandover() hostbox.ServingHandover {
+ return hostbox.ServingHandover{ColdHandover: hostbox.ColdHandover{Guard: h.Stop.Coordinator.Guard, Claims: h.Stop.Coordinator.Evidence, Registry: h.Reconcile.Registry, Inventory: h.Reconcile.Inventory, Launches: h.Launches, Roots: h.Roots, JournalDir: filepath.Join(h.Reconcile.Retirement.JournalDir, "serving"), VerifyReceipt: verifyRetirementReceipt}, Validate: h.validateServingHandover}
+}
+
+func (h ClaimRootHTTP) validateServingHandover(ctx context.Context, c *hostclaim.Claim, prior hostguard.FenceTarget, next hostbox.ColdSuccessor, in hostbox.StopInventory, installation hostguard.RootInstallation) error {
+ if h.RequireNewWork == nil || h.InventoryActor == nil {
+ return hostbox.ErrUnsupported
+ }
+ if err := h.RequireNewWork(ctx); err != nil {
+ return err
+ }
+ actor, err := h.InventoryActor(ctx)
+ if err != nil {
+ return err
+ }
+ if !actor.Eligible || actor.Quarantined || actor.Revision == 0 || actor.Box != prior.Box || actor.Region != prior.Region || actor.Enrollment != prior.Enrollment || strconv.FormatUint(actor.Revision, 10) != next.DirectoryRevision {
+ return hostbox.ErrChanged
+ }
+ if !servingPriorRevision(prior.DirectoryRevision, actor.Revision) {
+ return hostbox.ErrChanged
+ }
+ current, err := servingWriterIdentity(hostclaim.Observed{Host: prior.Host, Claim: c}, actor.Box, actor.Region)
+ if err != nil {
+ return err
+ }
+ holder := c.Holder
+ if installation.Empty || installation.Host != prior.Host || installation.Incarnation != prior.Incarnation || installation.Stamp != prior.Stamp || installation.Migration == 0 || installation.Account != holder.Account || installation.App != holder.App || installation.Record != holder.Key || installation.Box != actor.Box || installation.Enrollment != actor.Enrollment {
+ return hostbox.ErrChanged
+ }
+ canonical, _, err := rootClaimRecord(ctx, h.Stop.Coordinator.Evidence, c, h.Roots["data"])
+ if err != nil {
+ return err
+ }
+ canonical.ClaimIncarnation, canonical.ClaimStamp = prior.Incarnation, prior.Stamp
+ for _, e := range in.Entries {
+ if e.Kind == "route" {
+ canonical.ClaimOwner, canonical.ClaimRowID = e.Ownership, e.RowIdentity
+ }
+ }
+ if err = hostbox.ValidateAgentInventory(in, &canonical, h.Roots["data"]); err != nil {
+ return err
+ }
+ var admission hostclaim.AdmissionRecord
+ for _, a := range holder.Admissions {
+ if a.Session == current.Session {
+ admission = a
+ }
+ }
+ if prior.Session != 0 {
+ p, err := servingPredecessor(c, current, prior.Stamp, admission.Predecessor)
+ if err != nil || p.Session != prior.Session || admission.Predecessor != p.Generation || admission.PredecessorRegion != p.Region || admission.WritesLost == nil || admission.WritesLost.Box != p.Box || admission.WritesLost.Session != p.Session || admission.WritesLost.Generation != p.Generation {
+ return hostbox.ErrChanged
+ }
+ for _, a := range holder.Admissions {
+ if a.Session > p.Session && a.Session < current.Session {
+ return hostbox.ErrChanged
+ }
+ }
+ } else {
+ if h.servingParks == nil || h.servingParks.state == nil {
+ return hostbox.ErrUnsupported
+ }
+ body, err := h.servingParks.state.Read(servingLegacyKey("legacy-stop", current))
+ if err != nil {
+ return err
+ }
+ var saved servingLegacyStop
+ if servingParkDecode(body, &saved) != nil || validateServingLegacyStop(saved) != nil || saved.Result.Target != prior {
+ return hostbox.ErrChanged
+ }
+ old := saved.Observed.Claim.Holder
+ if old.Account != holder.Account || old.App != holder.App || old.Key != holder.Key || old.Incarnation != holder.Incarnation || !old.CreatedAt.Equal(holder.CreatedAt) || admission.Predecessor != old.Generation {
+ return hostbox.ErrChanged
+ }
+ for _, a := range holder.Admissions {
+ if a.Session < current.Session {
+ return hostbox.ErrChanged
+ }
+ }
+ if old.WriterBox == "" {
+ if admission.WritesLost != nil || admission.PredecessorRegion != "" {
+ return hostbox.ErrChanged
+ }
+ } else if old.WriterBox != actor.Box || admission.WritesLost == nil || admission.WritesLost.Box != actor.Box || admission.WritesLost.Session != 0 || admission.WritesLost.Generation != "" || admission.PredecessorRegion != actor.Region {
+ return hostbox.ErrChanged
+ }
+ }
+ fresh, err := hostclaim.Read(ctx, h.Stop.Coordinator.Evidence, prior.Host)
+ if err != nil {
+ return err
+ }
+ if fresh.Absent || !reflect.DeepEqual(fresh.Claim, c) {
+ return hostbox.ErrChanged
+ }
+ return nil
+}
+
+// Session zero sends a selector. Root supplies the complete retained stop tuple.
+func (h ClaimRootHTTP) servingLegacyHandoverTarget(ctx context.Context, q coldHandoverRequest) (hostguard.FenceTarget, error) {
+ if h.servingParks == nil || h.servingParks.state == nil {
+ return hostguard.FenceTarget{}, hostbox.ErrUnsupported
+ }
+ p, n := q.Prior, q.Next
+ selector := hostguard.FenceTarget{Host: n.Host, Incarnation: n.Incarnation, Stamp: p.Stamp, Box: n.Box, Enrollment: n.Enrollment}
+ if p != selector || n.Op != "" || n.Phase != "" {
+ return hostguard.FenceTarget{}, hostbox.ErrChanged
+ }
+ body, err := h.servingParks.state.Read(servingLegacyKey("legacy-stop", servingArtifactIdentity{Host: n.Host, Box: n.Box, Incarnation: n.Incarnation}))
+ if err != nil {
+ return hostguard.FenceTarget{}, err
+ }
+ var saved servingLegacyStop
+ if servingParkDecode(body, &saved) != nil || validateServingLegacyStop(saved) != nil {
+ return hostguard.FenceTarget{}, hostbox.ErrChanged
+ }
+ t := saved.Result.Target
+ if t.Host != p.Host || t.Incarnation != p.Incarnation || t.Stamp != p.Stamp || t.Box != p.Box || t.Enrollment != p.Enrollment {
+ return hostguard.FenceTarget{}, hostbox.ErrChanged
+ }
+ return t, ctx.Err()
+}
+
+func servingPriorRevision(recorded string, current uint64) bool {
+ revision, err := strconv.ParseUint(recorded, 10, 64)
+ return err == nil && revision > 0 && revision <= current && strconv.FormatUint(revision, 10) == recorded
+}
diff --git a/internal/host/claimservinghandover_linux_test.go b/internal/host/claimservinghandover_linux_test.go
index 1b2640714..a7ab85c95 100644
--- a/internal/host/claimservinghandover_linux_test.go
+++ b/internal/host/claimservinghandover_linux_test.go
@@ -4,115 +4,258 @@ package host
import (
"context"
- "crypto/sha256"
- "encoding/hex"
- "errors"
- "fmt"
+ "encoding/json"
"os"
"path/filepath"
+ "reflect"
+ "strconv"
"testing"
"github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostguard"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
- "golang.org/x/sys/unix"
)
-// Characterize the installation gate still separating the preparation fixture
-// from production. ColdHandover parks the whole state directory. A serving
-// handover must preserve it before the database-only retirement can be bound.
-func TestServingParkColdHandoverRequiresStatePreservation(t *testing.T) {
- runServingColdHandoverBoundary(t, nil)
-}
-
-func runServingColdHandoverBoundary(t *testing.T, mount func(*testing.T, string)) {
+// Initial installation remains a fixture. Every later ownership transfer uses
+// the production serving handover and retains the original source directory.
+func servingHandoverRoot(t *testing.T, f *servingRootFixture) ClaimRootHTTP {
t.Helper()
- f := newServingRootFixture(t, true)
- if mount != nil {
- mount(t, f.f.guard.DataDir)
- }
- ctx := t.Context()
inventory := f.p.root.Journals.Inventory
- captured := hostguard.Captured{Incarnation: f.prior.Incarnation, Stamp: f.prior.Stamp}
- in, _, err := inventory.Snapshot(ctx, f.prior.Host, captured)
- if err != nil || len(in.Entries) != 1 {
- t.Fatal("predecessor state inventory", err)
- }
- owner := registry.ClaimOwnership{ClaimIncarnation: f.prior.Incarnation, ClaimStamp: f.prior.Stamp, ClaimOwner: in.Entries[0].Ownership, ClaimRowID: "prior-route"}
- reg := restartTestRegistry(t)
- row := registry.Route{Host: f.prior.Host, Account: f.admitted.Claim.Holder.Account, App: f.admitted.Claim.Holder.App, Kind: "instance", DBPath: f.db, Socket: provision.NestedSocket(f.prior.Host), ClaimOwnership: owner}
- if err := reg.Upsert(row); err != nil {
- t.Fatal(err)
- }
- in.Entries = append(in.Entries, hostbox.StopInventoryEntry{Kind: "route", ArtifactID: owner.ClaimRowID, ManagedRoot: "registry", RelativePath: row.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"})
- if err := inventory.WriteStopInventory(ctx, in); err != nil {
+ captured, err := f.f.guard.Capture(f.f.read().Host)
+ if err != nil {
t.Fatal(err)
}
- _, revision, err := inventory.Snapshot(ctx, row.Host, captured)
+ in, _, err := inventory.Snapshot(t.Context(), f.f.read().Host, captured)
if err != nil {
t.Fatal(err)
}
- // Fixture bootstrap only. The transition below uses production handover,
- // root inventory refresh, exact receipt verification and the typed parker.
- f.mutateRoot(func(m map[string]any) { m["Installation"].(map[string]any)["InventoryRevision"] = revision })
- guard := f.f.guard
- guard.Root = hostguard.NewRootAuthorityWithInventory(f.rootDisk, func(ctx context.Context, host string, c hostguard.Captured) (uint64, error) {
+ if f.p.root.Reconcile.Registry == nil {
+ owner := registry.ClaimOwnership{ClaimIncarnation: in.Incarnation, ClaimStamp: in.InstallationStamp, ClaimOwner: in.Entries[0].Ownership, ClaimRowID: "prior-route"}
+ reg := restartTestRegistry(t)
+ holder := f.f.read().Claim.Holder
+ row := registry.Route{Host: in.Host, Account: holder.Account, App: holder.App, Kind: "instance", DBPath: f.db, Socket: provision.NestedSocket(in.Host), ClaimOwnership: owner}
+ if err := reg.Upsert(row); err != nil {
+ t.Fatal(err)
+ }
+ in.Entries = append(in.Entries, hostbox.StopInventoryEntry{Kind: "route", ArtifactID: owner.ClaimRowID, ManagedRoot: "registry", RelativePath: in.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"})
+ if err := inventory.WriteStopInventory(t.Context(), in); err != nil {
+ t.Fatal(err)
+ }
+ _, revision, err := inventory.Snapshot(t.Context(), in.Host, captured)
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, err := f.rootDisk.Read(in.Host + ".json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var saved map[string]any
+ if err = json.Unmarshal(raw, &saved); err != nil {
+ t.Fatal(err)
+ }
+ saved["Installation"].(map[string]any)["InventoryRevision"] = revision
+ raw, err = json.Marshal(saved)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = f.rootDisk.Write(in.Host+".json", raw); err != nil {
+ t.Fatal(err)
+ }
+ record := provision.Record{Schema: 1, Host: in.Host, Account: holder.Account, App: holder.App, Region: f.actor.Region, CreatedAt: holder.CreatedAt, Channel: "edge"}
+ body, err := json.Marshal(record)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = f.f.claims.Put(t.Context(), holder.Key, body, ""); err != nil {
+ t.Fatal(err)
+ }
+ f.p.root.Reconcile.Registry = reg
+ f.p.root.Reconcile.Inventory = inventory
+ f.p.root.Reconcile.Box, f.p.root.Reconcile.Enrollment = f.actor.Box, f.actor.Enrollment
+ f.p.root.Reconcile.Retirement.JournalDir = filepath.Join(t.TempDir(), "handover")
+ f.p.root.Launches = f.f.journal
+ }
+ f.f.guard.Root = hostguard.NewRootAuthorityWithInventory(f.rootDisk, func(ctx context.Context, host string, c hostguard.Captured) (uint64, error) {
_, rev, err := inventory.Snapshot(ctx, host, c)
return rev, err
})
+ f.p.root.Stop.Coordinator.Guard = f.f.guard
+ f.f.evidence = f.f.claims
+ f.p.root.Stop.Coordinator.Evidence = f.f.claims
+ return servingTransportRoot(t, f)
+}
+
+func TestServingParkColdHandoverPreservesState(t *testing.T) { runServingColdHandoverBoundary(t, nil) }
+
+func runServingColdHandoverBoundary(t *testing.T, mount func(*testing.T, string), revisions ...uint64) {
+ t.Helper()
+ f := newServingRootFixture(t, true)
+ if mount != nil {
+ mount(t, f.f.guard.DataDir)
+ }
+ root := servingHandoverRoot(t, f)
+ if len(revisions) != 0 {
+ f.actor.Revision = revisions[0]
+ }
stateDir := filepath.Dir(f.db)
- for name, body := range map[string]string{"files/upload": "tenant upload", "env": "tenant configuration"} {
- path := filepath.Join(stateDir, name)
- if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ expected := map[string]string{filepath.Base(f.db): "served bytes", filepath.Base(f.db) + ".gen": "G", filepath.Base(f.db) + "-wal": "WAL", filepath.Base(f.db) + "-shm": "SHM", "files/upload": "tenant upload", "env": "tenant configuration"}
+ for _, name := range []string{"files/upload", "env"} {
+ p := filepath.Join(stateDir, name)
+ if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil {
t.Fatal(err)
}
- if err := os.WriteFile(path, []byte(body), 0600); err != nil {
+ if err := os.WriteFile(p, []byte(expected[name]), 0600); err != nil {
t.Fatal(err)
}
}
before := f.f.read()
- next := hostbox.ColdSuccessor{Host: f.current.Host, Incarnation: f.current.Incarnation, Box: f.actor.Box, Enrollment: f.actor.Enrollment, DirectoryRevision: "7", ETag: before.ETag, Generation: f.current.Generation, Stamp: f.current.Stamp, Session: f.current.Session}
- handover := hostbox.ColdHandover{Guard: guard, Claims: f.f.claims, Registry: reg, Inventory: inventory, Launches: f.f.journal, Roots: f.p.root.Roots, JournalDir: filepath.Join(t.TempDir(), "handover"), VerifyReceipt: verifyRetirementReceipt}
- err = handover.Run(ctx, f.receipt.Target, next)
- if mount != nil {
- if !errors.Is(err, unix.EXDEV) {
- t.Fatalf("production cold handover across the instance bind: %v", err)
- }
- for name, want := range map[string]string{filepath.Base(f.db): "served bytes", filepath.Base(f.db) + ".gen": "G", filepath.Base(f.db) + "-wal": "WAL", filepath.Base(f.db) + "-shm": "SHM", "files/upload": "tenant upload", "env": "tenant configuration"} {
- body, err := os.ReadFile(filepath.Join(stateDir, name))
- if err != nil || string(body) != want {
- t.Fatalf("failed handover changed %s: %q %v", name, body, err)
- }
+ old, _, err := root.Reconcile.Inventory.Snapshot(t.Context(), before.Host, hostguard.Captured{Incarnation: f.prior.Incarnation, Stamp: f.prior.Stamp})
+ if err != nil {
+ t.Fatal(err)
+ }
+ next := hostbox.ColdSuccessor{Host: f.current.Host, Incarnation: f.current.Incarnation, Box: f.actor.Box, Enrollment: f.actor.Enrollment, DirectoryRevision: strconv.FormatUint(f.actor.Revision, 10), ETag: before.ETag, Generation: f.current.Generation, Stamp: f.current.Stamp, Session: f.current.Session}
+ handover := root.servingHandover()
+ for i := 0; i < 2; i++ {
+ if err = handover.Run(t.Context(), f.receipt.Target, next); err != nil {
+ t.Fatal("serving handover", err)
}
- if f.f.read().ETag != before.ETag {
- t.Fatal("failed handover rewrote the claim")
+ }
+ for name, want := range expected {
+ body, err := os.ReadFile(filepath.Join(stateDir, name))
+ if err != nil || string(body) != want {
+ t.Fatalf("handover changed %s: %q %v", name, body, err)
}
- return
}
+ retained, _, err := root.Reconcile.Inventory.Snapshot(t.Context(), before.Host, hostguard.Captured{Incarnation: f.prior.Incarnation, Stamp: f.prior.Stamp})
+ if err != nil || !reflect.DeepEqual(old, retained) {
+ t.Fatal("predecessor inventory changed", err)
+ }
+ if f.f.read().ETag != before.ETag {
+ t.Fatal("handover rewrote claim")
+ }
+ a, err := f.park()
if err != nil {
- t.Fatal("production cold handover", err)
+ t.Fatal("database retirement after handover", err)
}
- if _, err := os.Lstat(stateDir); !os.IsNotExist(err) {
- t.Fatalf("cold handover now preserves state; replace this gate witness with the serving integration: %v", err)
+ f.tags(f.current.Stamp)
+ if err = f.p.completeServingDatabasePark(t.Context(), f.conn, a.ID, f.current); err != nil {
+ t.Fatal(err)
}
- sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", next.Host, next.Incarnation, next.Stamp, next.Box)))
- parked := filepath.Join(guard.DataDir, ".parked", next.Incarnation, next.Host, "handover-"+hex.EncodeToString(sum[:])+"-old", "data", "instances", provision.InstanceID(next.Host))
- for name, want := range map[string]string{filepath.Base(f.db): "served bytes", filepath.Base(f.db) + ".gen": "G", filepath.Base(f.db) + "-wal": "WAL", filepath.Base(f.db) + "-shm": "SHM", "files/upload": "tenant upload", "env": "tenant configuration"} {
- body, err := os.ReadFile(filepath.Join(parked, name))
- if err != nil || string(body) != want {
- t.Fatalf("cold handover retained %s: %q %v", name, body, err)
+ for _, name := range []string{"files/upload", "env"} {
+ body, err := os.ReadFile(filepath.Join(stateDir, name))
+ if err != nil || string(body) != expected[name] {
+ t.Fatalf("retirement changed %s: %q %v", name, body, err)
}
}
- f.p.root.Stop.Coordinator.Guard = guard
- if _, err := f.park(); err == nil {
- t.Fatal("database retirement accepted the removed source")
+}
+
+func TestServingHandoverLegacy(t *testing.T) {
+ f, _, coordinator := newServingLegacyRootSetup(t, false)
+ root := servingHandoverRoot(t, f)
+ o := f.f.read()
+ req := StopRequest{Region: f.actor.Region, Host: o.Host, Incarnation: o.Claim.Holder.Incarnation, InstallationStamp: o.Claim.Holder.Stamp, Box: f.actor.Box, Enrollment: f.actor.Enrollment, BootID: "boot", ControllerID: "controller", ExpectedClaimETag: o.ETag, ExpectedDirectoryRevision: "7"}
+ if _, err := coordinator.FenceAndStop(t.Context(), req); err != nil {
+ t.Fatal(err)
}
- after := f.f.read()
- if after.ETag != before.ETag {
- t.Fatal("handover or refused park rewrote claim tags")
+ admitted, err := hostclaim.Update(t.Context(), f.f.claims, f.f.read(), hostclaim.AdmitWriter(hostclaim.Admission{ObservedSession: 0, Box: f.actor.Box, Region: f.actor.Region, PredecessorRegion: f.actor.Region, Mode: "cold", NewGeneration: "G"}))
+ if err != nil {
+ t.Fatal(err)
}
- if names, err := f.disk.Names(); err != nil || len(names) != 0 {
- t.Fatalf("removed source gained serving retirement authority: %v %v", names, err)
+ f.current, err = servingWriterIdentity(admitted, f.actor.Box, f.actor.Region)
+ if err != nil {
+ t.Fatal(err)
+ }
+ saved, err := f.p.ledger.legacyStop(f.current)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.actor.Revision = 8
+ n := hostbox.ColdSuccessor{Host: f.current.Host, Incarnation: f.current.Incarnation, Box: f.actor.Box, Enrollment: f.actor.Enrollment, DirectoryRevision: strconv.FormatUint(f.actor.Revision, 10), ETag: f.f.read().ETag, Generation: f.current.Generation, Stamp: f.current.Stamp, Session: f.current.Session}
+ if err := root.servingHandover().Run(t.Context(), saved.Result.Target, n); err != nil {
+ t.Fatalf("legacy transfer: %v prior=%+v next=%+v", err, saved.Result.Target, n)
+ }
+ if _, err := f.p.parkLegacyServingDatabase(t.Context(), f.conn, f.current, servingLegacyPredecessor{}); err != nil {
+ t.Fatal("legacy park after transfer", err)
+ }
+}
+
+func TestServingHandoverRefusesForeignAuthority(t *testing.T) {
+ for _, attack := range []string{"census", "actor", "successor", "directory-revision", "operation", "predecessor", "receipt", "root-revision", "registry-path", "registry-account", "source-replaced", "extra-timer", "prelaunch"} {
+ t.Run(attack, func(t *testing.T) {
+ f := newServingRootFixture(t, true)
+ root := servingHandoverRoot(t, f)
+ p := f.receipt.Target
+ n := hostbox.ColdSuccessor{Host: f.current.Host, Incarnation: f.current.Incarnation, Box: f.actor.Box, Enrollment: f.actor.Enrollment, DirectoryRevision: "7", ETag: f.f.read().ETag, Generation: f.current.Generation, Stamp: f.current.Stamp, Session: f.current.Session}
+ row, err := root.Reconcile.Registry.Get(n.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ switch attack {
+ case "census":
+ root.RequireNewWork = func(context.Context) error { return hostbox.ErrChanged }
+ case "actor":
+ f.actor.Eligible = false
+ case "successor":
+ n.Generation = "foreign"
+ case "directory-revision":
+ n.DirectoryRevision = "8"
+ case "operation":
+ n.Op = "move"
+ case "predecessor":
+ p.Session++
+ case "receipt":
+ p.FenceID = "unknown"
+ case "root-revision":
+ f.mutateRoot(func(m map[string]any) { m["Installation"].(map[string]any)["InventoryRevision"] = 999 })
+ case "registry-path":
+ row.DBPath = filepath.Join(t.TempDir(), "victim.db")
+ if err = root.Reconcile.Registry.Upsert(*row); err != nil {
+ t.Fatal(err)
+ }
+ case "registry-account":
+ row.Account = "bac"
+ if err = root.Reconcile.Registry.Upsert(*row); err != nil {
+ t.Fatal(err)
+ }
+ case "source-replaced":
+ dir := filepath.Dir(f.db)
+ if err = os.Rename(dir, dir+"-original"); err != nil {
+ t.Fatal(err)
+ }
+ if err = os.Mkdir(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ case "extra-timer":
+ if err = root.Reconcile.Registry.UpsertTimer(registry.Timer{Host: n.Host, Name: "foreign", Kind: "cron", Spec: "0 * * * *"}); err != nil {
+ t.Fatal(err)
+ }
+ case "prelaunch":
+ target := recoveryTuple(f.f.read(), claimStopLocal{Actor: f.actor, BootID: "boot", ControllerID: "controller"})
+ if err = f.f.journal.Prelaunch(t.Context(), hostbox.LaunchInventory{Target: target, ControllerPID: 77, ProcessStart: "edge-start", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "carlos/next", Device: 1, Inode: 99}}}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ before, err := root.Reconcile.Registry.Get(n.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = root.servingHandover().Run(t.Context(), p, n); err == nil {
+ t.Fatal("accepted", attack)
+ }
+ after, err := root.Reconcile.Registry.Get(n.Host)
+ if err != nil || !reflect.DeepEqual(before, after) {
+ t.Fatal("refusal changed row", err)
+ }
+ source := f.db
+ if attack == "source-replaced" {
+ source = filepath.Join(filepath.Dir(f.db)+"-original", filepath.Base(f.db))
+ }
+ body, err := os.ReadFile(source)
+ if err != nil || string(body) != "served bytes" {
+ t.Fatal("refusal changed source", err)
+ }
+ })
}
}
diff --git a/internal/host/claimservinglegacyroot_linux.go b/internal/host/claimservinglegacyroot_linux.go
index 47d042597..459c9c82a 100644
--- a/internal/host/claimservinglegacyroot_linux.go
+++ b/internal/host/claimservinglegacyroot_linux.go
@@ -99,7 +99,7 @@ func (p *servingParkRoot) validateLegacyRootEvidence(ctx context.Context, o host
return hostbox.ErrChanged
}
t, h := saved.Result.Target, saved.Observed.Claim.Holder
- if t.Enrollment != actor.Enrollment || t.DirectoryRevision != strconv.FormatUint(actor.Revision, 10) || h.Account != o.Claim.Holder.Account || h.App != o.Claim.Holder.App || h.Key != o.Claim.Holder.Key || !h.CreatedAt.Equal(o.Claim.Holder.CreatedAt) {
+ if t.Enrollment != actor.Enrollment || !servingPriorRevision(t.DirectoryRevision, actor.Revision) || h.Account != o.Claim.Holder.Account || h.App != o.Claim.Holder.App || h.Key != o.Claim.Holder.Key || !h.CreatedAt.Equal(o.Claim.Holder.CreatedAt) {
return hostbox.ErrChanged
}
for _, r := range o.Claim.Holder.Admissions {
diff --git a/internal/host/claimservingpark_transport_linux_test.go b/internal/host/claimservingpark_transport_linux_test.go
index a3edb257e..d36c268b6 100644
--- a/internal/host/claimservingpark_transport_linux_test.go
+++ b/internal/host/claimservingpark_transport_linux_test.go
@@ -21,6 +21,10 @@ import (
)
func servingParkSocket(t *testing.T, handler http.Handler) hostbox.JournalCall {
+ t.Helper()
+ return rootJournalClient(servingRootSocket(t, handler))
+}
+func servingRootSocket(t *testing.T, handler http.Handler) string {
t.Helper()
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "serving-park-")
if err != nil {
@@ -42,13 +46,13 @@ func servingParkSocket(t *testing.T, handler http.Handler) hostbox.JournalCall {
t.Error(err)
}
})
- return rootJournalClient(path)
+ return path
}
func servingTransportRoot(t *testing.T, f *servingRootFixture) ClaimRootHTTP {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
- p := &servingParkRuntime{authority: f.p, state: f.disk, ctx: ctx, cancel: cancel, idle: make(chan struct{})}
+ p := &servingParkRuntime{authority: f.p, state: servingBorrowedState{f.p.ledger.state}, ctx: ctx, cancel: cancel, idle: make(chan struct{})}
t.Cleanup(func() {
if err := p.Close(t.Context()); err != nil {
t.Error(err)
@@ -347,3 +351,7 @@ func TestServingParkProductionBindingRetainsSessionZero(t *testing.T) {
t.Fatal("failed binding leaked private store", err)
}
}
+
+type servingBorrowedState struct{ rootstate.Store }
+
+func (servingBorrowedState) Close() error { return nil }
diff --git a/internal/host/claimservingparkroot_linux.go b/internal/host/claimservingparkroot_linux.go
index 7004e9eea..97a3d2e4e 100644
--- a/internal/host/claimservingparkroot_linux.go
+++ b/internal/host/claimservingparkroot_linux.go
@@ -8,7 +8,6 @@ import (
"net"
"path/filepath"
"sort"
- "strconv"
"time"
"github.com/carlosframework/platform/internal/hostbox"
@@ -129,7 +128,7 @@ func servingRootStops(ctx context.Context, view hostguard.FenceView, prior servi
if t.Host != prior.Host || t.Incarnation != prior.Incarnation || t.Stamp != prior.Stamp || t.Box != prior.Box || t.Op != "" || t.Phase != "" || t.ObligationID != "" {
continue
}
- if t.Session != prior.Session || t.Region != prior.Region || t.Enrollment != actor.Enrollment || t.DirectoryRevision != strconv.FormatUint(actor.Revision, 10) || t.FenceID == "" || seen[t.FenceID] || verifyRetirementReceipt(ctx, receipt) != nil {
+ if t.Session != prior.Session || t.Region != prior.Region || t.Enrollment != actor.Enrollment || !servingPriorRevision(t.DirectoryRevision, actor.Revision) || t.FenceID == "" || seen[t.FenceID] || verifyRetirementReceipt(ctx, receipt) != nil {
return nil, ErrClaimStopReceipt
}
seen[t.FenceID] = true
diff --git a/internal/host/claimservingpeer_rootrunner_linux_test.go b/internal/host/claimservingpeer_rootrunner_linux_test.go
index bd21f49d0..85033015e 100644
--- a/internal/host/claimservingpeer_rootrunner_linux_test.go
+++ b/internal/host/claimservingpeer_rootrunner_linux_test.go
@@ -21,6 +21,7 @@ import (
"github.com/carlosframework/platform/internal/cgroup"
"github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostguard"
"github.com/carlosframework/platform/internal/instancectl"
"github.com/carlosframework/platform/internal/rootstate"
@@ -266,6 +267,7 @@ func TestRootServingParkAuthenticatedPeer(t *testing.T) {
return nil
}
h := servingTransportRoot(t, f)
+ h.servingParks.authority = servingPeerParkTrace{servingParkAuthority: h.servingParks.authority, root: f.p, t: t}
path := filepath.Join(root, "root.sock")
listener, err := net.Listen("unix", path)
if err != nil {
@@ -438,9 +440,45 @@ func TestRootServingParkAuthenticatedPeer(t *testing.T) {
}
}
-// Exercise the actual earlier cold-handover parker on the nested mount. This
-// is a refusal witness, not successful serving installation or Restore.
+// Transfer the serving installation on the nested mount without moving its
+// state directory, then park the database through the root transport.
func TestRootServingParkColdHandoverBoundary(t *testing.T) {
servingRootRunner(t)
runServingColdHandoverBoundary(t, servingRunnerMount)
}
+
+// Keep root errors in the test log; the public transport intentionally returns
+// only uncertainty after a durable request may have made filesystem changes.
+type servingPeerParkTrace struct {
+ servingParkAuthority
+ root *servingParkRoot
+ t *testing.T
+}
+
+func (p servingPeerParkTrace) parkServingDatabase(ctx context.Context, conn net.Conn, current, prior servingArtifactIdentity) (servingParkAuthorization, error) {
+ a, err := p.servingParkAuthority.parkServingDatabase(ctx, conn, current, prior)
+ p.t.Logf("authenticated peer park authority: %v", err)
+ if err != nil {
+ check := p.root.withAuthority(ctx, conn, current, func(o hostclaim.Observed, view hostguard.FenceView, actor hostbox.Actor, dir servingArtifactDirectory, tags uint64) error {
+ selected, e := servingPredecessor(o.Claim, current, tags, prior.Generation)
+ p.t.Logf("park diagnostic predecessor: exact=%v error=%v", selected == prior, e)
+ receipts, e := servingRootStops(ctx, view, prior, actor)
+ p.t.Logf("park diagnostic receipts: count=%d error=%v", len(receipts), e)
+ source, e := p.root.ledger.fs.Capture(servingParkAuthorization{TagsFrom: tags, Predecessor: prior, OriginalSuccessor: current, Successor: current, Directory: dir, ID: servingRetirementID(prior, current, false)})
+ p.t.Logf("park diagnostic source capture: %v", e)
+ if e == nil {
+ disposition, e := servingGenerationParkDisposition(ctx, p.root.ledger.journal, prior, receipts, source.Files[0].Present)
+ p.t.Logf("park diagnostic disposition: %v error=%v", disposition, e)
+ }
+ return nil
+ })
+ p.t.Logf("park diagnostic root installation: %v", check)
+ }
+ return a, err
+}
+
+func (p servingPeerParkTrace) completeServingDatabasePark(ctx context.Context, conn net.Conn, id string, current servingArtifactIdentity) error {
+ err := p.servingParkAuthority.completeServingDatabasePark(ctx, conn, id, current)
+ p.t.Logf("authenticated peer completion authority: %v", err)
+ return err
+}
diff --git a/internal/hostbox/coldhandover.go b/internal/hostbox/coldhandover.go
index 166d489a0..9319d1590 100644
--- a/internal/hostbox/coldhandover.go
+++ b/internal/hostbox/coldhandover.go
@@ -25,28 +25,30 @@ type ColdSuccessor struct {
Stamp, Session uint64
}
type ColdHandover struct {
- checkpoint func(string)
- afterIntent func() error
- afterPark func() error
- Guard hostguard.Guard
- Claims release.Store
- Registry *registry.Registry
- Inventory *StopInventoryDB
- Launches *LaunchJournal
- Roots map[string]string
- JournalDir string
- VerifyReceipt hostguard.CleanupVerify
+ OperationsOnly bool
+ checkpoint func(string)
+ afterIntent func() error
+ afterPark func() error
+ Guard hostguard.Guard
+ Claims release.Store
+ Registry *registry.Registry
+ Inventory *StopInventoryDB
+ Launches *LaunchJournal
+ Roots map[string]string
+ JournalDir string
+ VerifyReceipt hostguard.CleanupVerify
}
type coldJournal struct {
- V int
- IntendedAbsent bool
- ID, Phase string
- Prior hostguard.FenceTarget
- Barrier hostguard.FenceTarget
- Successor ColdSuccessor
- Inventory StopInventory
- Receipt hostguard.FenceReceipt
- Before, After registry.ColdRows
+ ServingInstallation *hostguard.RootInstallation `json:",omitempty"`
+ V int
+ IntendedAbsent bool
+ ID, Phase string
+ Prior hostguard.FenceTarget
+ Barrier hostguard.FenceTarget
+ Successor ColdSuccessor
+ Inventory StopInventory
+ Receipt hostguard.FenceReceipt
+ Before, After registry.ColdRows
}
func ownershipFor(host, incarnation string, stamp uint64, box string) string {
@@ -58,6 +60,9 @@ func rowIDFor(owner, kind, name string) string {
return hex.EncodeToString(sum[:])
}
func (h ColdHandover) Run(ctx context.Context, prior hostguard.FenceTarget, next ColdSuccessor) error {
+ if h.OperationsOnly && (next.Op == "" || next.Phase == "") {
+ return ErrUnsupported
+ }
if h.Claims == nil || h.Registry == nil || h.Inventory == nil || h.Launches == nil || h.VerifyReceipt == nil || !filepath.IsAbs(h.JournalDir) || len(h.Roots) == 0 || next.Host != prior.Host || next.Incarnation != prior.Incarnation || next.Stamp <= prior.Stamp || next.Session <= prior.Session || next.Stamp != next.Session || next.Box != prior.Box || next.Enrollment != prior.Enrollment || next.ETag == "" || next.Generation == "" {
return ErrUnsupported
}
diff --git a/internal/hostbox/servinghandover.go b/internal/hostbox/servinghandover.go
new file mode 100644
index 000000000..8109e0ee5
--- /dev/null
+++ b/internal/hostbox/servinghandover.go
@@ -0,0 +1,288 @@
+package hostbox
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// ServingHandover retains the stopped installation's state directory. Its
+// barrier covers inventory and row transfer; database retirement follows later.
+type ServingHandover struct {
+ ColdHandover
+ Validate func(context.Context, *hostclaim.Claim, hostguard.FenceTarget, ColdSuccessor, StopInventory, hostguard.RootInstallation) error
+ checkpoint func(string) error
+}
+
+func (h ServingHandover) Run(ctx context.Context, prior hostguard.FenceTarget, next ColdSuccessor) error {
+ if h.Guard.Root == nil || h.Validate == nil || h.Claims == nil || h.Registry == nil || h.Inventory == nil || h.Inventory.Remote() || h.Launches == nil || h.VerifyReceipt == nil || !filepath.IsAbs(h.JournalDir) || h.Roots["data"] != h.Guard.DataDir || prior.Op != "" || prior.Phase != "" || prior.ObligationID != "" || next.Op != "" || next.Phase != "" || next.Host != prior.Host || next.Incarnation != prior.Incarnation || next.Box != prior.Box || next.Enrollment != prior.Enrollment || next.Stamp <= prior.Stamp || next.Session != next.Stamp || next.Session <= prior.Session || next.Generation == "" || next.ETag == "" {
+ return ErrUnsupported
+ }
+ id := "serving-handover-" + ownershipFor(next.Host, next.Incarnation, next.Stamp, next.Box)
+ path := filepath.Join(h.JournalDir, id+".json")
+ var j coldJournal
+ body, err := readTrustedJournal(path)
+ if err == nil {
+ if decodeStrict(body, &j) != nil || j.V != 1 || j.ID != id || j.Prior != prior || j.Successor != next || j.ServingInstallation == nil || (j.Phase != "prepared" && j.Phase != "complete") {
+ return ErrTypedJournal
+ }
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return err
+ } else {
+ captured := hostguard.Captured{Incarnation: prior.Incarnation, Stamp: prior.Stamp}
+ view, err := h.Guard.Root.View(ctx, prior.Host, captured)
+ if err != nil {
+ return err
+ }
+ in, rev, err := h.Inventory.Snapshot(ctx, prior.Host, captured)
+ if err != nil {
+ return err
+ }
+ if view.Installation.Stamp != prior.Stamp || view.Installation.InventoryRevision != rev || !in.Complete || in.Box != prior.Box || in.Enrollment != prior.Enrollment || in.DirectoryRevision != prior.DirectoryRevision {
+ return ErrTypedInventory
+ }
+ var receipt hostguard.FenceReceipt
+ if prior.Session != 0 {
+ receipt, err = h.Guard.FenceReceiptFor(ctx, prior)
+ if err != nil {
+ return err
+ }
+ if err = h.VerifyReceipt(ctx, receipt); err != nil {
+ return err
+ }
+ }
+ row, err := h.Registry.Get(prior.Host)
+ if err != nil {
+ return err
+ }
+ if row == nil || row.Kind != "instance" || row.Account != view.Installation.Account || row.App != view.Installation.App || row.Socket != provision.NestedSocket(prior.Host) || row.DBPath != provision.NestedDBPath(h.Roots["data"], prior.Host) || row.Unit != "" || row.Addr != "" || row.Addrs != "" || row.Backing != "" || row.Tunnel != "" || row.Platform || row.Canary || row.Replica {
+ return ErrTypedInventory
+ }
+ timers, err := h.Registry.TimersFor(prior.Host)
+ if err != nil {
+ return err
+ }
+ before := registry.ColdRows{Route: *row, Timers: timers}
+ after := registry.ColdRows{Route: *row, Timers: append([]registry.Timer(nil), timers...)}
+ owner := ownershipFor(next.Host, next.Incarnation, next.Stamp, next.Box)
+ after.Route.ClaimOwnership = registry.ClaimOwnership{ClaimIncarnation: next.Incarnation, ClaimStamp: next.Stamp, ClaimOwner: owner, ClaimRowID: rowIDFor(owner, "route", next.Host)}
+ for i := range after.Timers {
+ after.Timers[i].ClaimOwnership = after.Route.ClaimOwnership
+ after.Timers[i].ClaimRowID = rowIDFor(owner, "timer", after.Timers[i].Name)
+ }
+ barrier := prior
+ barrier.FenceID, barrier.Stamp, barrier.Session, barrier.ClaimETag, barrier.DirectoryRevision = id, next.Stamp, next.Session, next.ETag, next.DirectoryRevision
+ j = coldJournal{V: 1, ID: id, Phase: "prepared", Prior: prior, Barrier: barrier, Successor: next, Inventory: in, Receipt: receipt, Before: before, After: after, ServingInstallation: &view.Installation}
+ err = h.Guard.WithServingRetirement(ctx, next.Host, hostguard.Captured{Incarnation: next.Incarnation, Stamp: next.Stamp}, func(fresh hostclaim.Observed, locked hostguard.FenceView) error {
+ if locked.Installation != view.Installation {
+ return ErrChanged
+ }
+ if fresh.ETag != next.ETag {
+ return ErrChanged
+ }
+ if err = h.validate(ctx, j, fresh.Claim); err != nil {
+ return err
+ }
+ saved, err := readTrustedJournal(path)
+ if err == nil {
+ var existing coldJournal
+ if decodeStrict(saved, &existing) != nil || !reflect.DeepEqual(existing, j) {
+ return ErrTypedJournal
+ }
+ return nil
+ }
+ if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ return writeColdJournal(path, j)
+ })
+ if err != nil {
+ return err
+ }
+ }
+ check := func(c *hostclaim.Claim) error { return h.validate(ctx, j, c) }
+ if j.Phase == "complete" {
+ return h.Guard.CompleteColdHandover(ctx, j.Barrier, true, check, func() error { return nil })
+ }
+ if err = h.at("intent"); err != nil {
+ return err
+ }
+ if err = h.Guard.BeginServingHandover(ctx, prior, j.Barrier, *j.ServingInstallation, check); err != nil {
+ return err
+ }
+ if err = h.at("barrier"); err != nil {
+ return err
+ }
+ return h.Guard.CompleteColdHandover(ctx, j.Barrier, false, check, func() error {
+ if prior.Session != 0 {
+ if err := h.VerifyReceipt(ctx, j.Receipt); err != nil {
+ return err
+ }
+ }
+ launches, err := h.Launches.RootSessionLaunches(ctx, next.Host, next.Incarnation, next.Stamp, next.Session, next.Box, next.Enrollment)
+ if err != nil {
+ return err
+ }
+ if len(launches) != 0 {
+ return ErrChanged
+ }
+ rebound := j.Inventory
+ rebound.Entries = append([]StopInventoryEntry(nil), j.Inventory.Entries...)
+ rebound.InstallationStamp, rebound.DirectoryRevision = next.Stamp, next.DirectoryRevision
+ for i := range rebound.Entries {
+ e := &rebound.Entries[i]
+ e.Ownership = j.After.Route.ClaimOwner
+ if e.Kind == "route" {
+ e.RowIdentity = j.After.Route.ClaimRowID
+ }
+ if e.Kind == "timer" {
+ for _, timer := range j.After.Timers {
+ if e.RelativePath == next.Host+"/"+timer.Name {
+ e.RowIdentity = timer.ClaimRowID
+ }
+ }
+ }
+ }
+ existing, err := h.Inventory.ReadInstallation(ctx, next.Host, next.Incarnation, next.Stamp)
+ if err == nil {
+ if !reflect.DeepEqual(existing, rebound) {
+ return ErrTypedInventory
+ }
+ } else if !errors.Is(err, sql.ErrNoRows) {
+ return err
+ } else if err = h.Inventory.WriteStopInventory(ctx, rebound); err != nil {
+ return err
+ }
+ if err = h.at("inventory"); err != nil {
+ return err
+ }
+ if err = h.Registry.RebindClaimRows(ctx, j.Before, j.After); err != nil {
+ return err
+ }
+ if err = h.at("rows"); err != nil {
+ return err
+ }
+ j.Phase = "complete"
+ if err = writeColdJournal(path, j); err != nil {
+ return err
+ }
+ return h.at("complete-journal")
+ })
+}
+
+func (h ServingHandover) validate(ctx context.Context, j coldJournal, c *hostclaim.Claim) error {
+ n, p := j.Successor, j.Prior
+ if c == nil || c.Host != n.Host || c.Holder == nil || c.Op.Kind != hostclaim.KindServing || c.Op.ID != "" || c.Op.Phase != "" || c.Op.Exec != n.Box || c.Holder.Incarnation != n.Incarnation || c.Holder.Stamp != n.Stamp || c.Holder.Session != n.Session || c.Holder.Generation != n.Generation || c.Holder.WriterBox != n.Box {
+ return ErrChanged
+ }
+ if j.ServingInstallation == nil || (p.Session != 0 && (j.Receipt.Target != p || h.VerifyReceipt(ctx, j.Receipt) != nil)) || (p.Session == 0 && !reflect.DeepEqual(j.Receipt, hostguard.FenceReceipt{})) {
+ return ErrTypedJournal
+ }
+ current, rev, err := h.Inventory.Snapshot(ctx, p.Host, hostguard.Captured{Incarnation: p.Incarnation, Stamp: p.Stamp})
+ if err != nil {
+ return err
+ }
+ if rev != j.ServingInstallation.InventoryRevision || !reflect.DeepEqual(current, j.Inventory) {
+ return ErrTypedInventory
+ }
+ if err = ValidateAgentInventory(j.Inventory, &j.Before.Route, h.Roots["data"]); err != nil {
+ return err
+ }
+ stateRelative, err := filepath.Rel(h.Roots["data"], filepath.Dir(provision.NestedDBPath(h.Roots["data"], p.Host)))
+ if err != nil {
+ return err
+ }
+ routes, paths, timers := 0, 0, 0
+ for _, e := range j.Inventory.Entries {
+ if e.Materialization != "materialized" || e.Ownership != j.Before.Route.ClaimOwner {
+ return ErrTypedInventory
+ }
+ switch e.Kind {
+ case "route":
+ routes++
+ case "path":
+ paths++
+ if e.ManagedRoot != "data" || e.RelativePath != stateRelative || e.Type != 0040000 || e.Device == 0 || e.Inode == 0 {
+ return ErrTypedInventory
+ }
+ a, err := (TypedParker{Roots: h.Roots, ParkedRoots: h.Roots, Incarnation: p.Incarnation, Host: p.Host, Retirement: j.ID}).CaptureArtifact(e.ManagedRoot, e.RelativePath, e.Ownership, ArtifactPath)
+ if err != nil {
+ return err
+ }
+ if a.Dev != e.Device || a.Ino != e.Inode || a.Type != e.Type {
+ return ErrChanged
+ }
+ case "timer":
+ timers++
+ found := 0
+ for _, timer := range j.Before.Timers {
+ if e.RelativePath == p.Host+"/"+timer.Name && timer.ClaimOwnership.Valid() && timer.ClaimIncarnation == p.Incarnation && timer.ClaimStamp == p.Stamp && timer.ClaimOwner == e.Ownership && timer.ClaimRowID == e.RowIdentity && timer.Registration != "" {
+ found++
+ }
+ }
+ if found != 1 {
+ return ErrTypedInventory
+ }
+ default:
+ return ErrTypedInventory
+ }
+ }
+ if routes != 1 || paths != 1 || timers != len(j.Before.Timers) {
+ return ErrTypedInventory
+ }
+ return h.Validate(ctx, c, p, n, j.Inventory, *j.ServingInstallation)
+}
+
+func (h ServingHandover) Recover(ctx context.Context) error {
+ entries, err := readTrustedJournalDir(h.JournalDir)
+ if err != nil {
+ return err
+ }
+ if len(entries) == 0 {
+ return nil
+ }
+ pending, err := h.Guard.PendingFences(ctx)
+ if err != nil {
+ return err
+ }
+ for _, e := range entries {
+ if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
+ continue
+ }
+ body, err := readTrustedJournal(filepath.Join(h.JournalDir, e.Name()))
+ if err != nil {
+ return err
+ }
+ var j coldJournal
+ if decodeStrict(body, &j) != nil || j.V != 1 || j.ID+".json" != e.Name() || j.ServingInstallation == nil {
+ return ErrTypedJournal
+ }
+ if j.Phase == "complete" {
+ found := false
+ for _, target := range pending {
+ found = found || target == j.Barrier
+ }
+ if !found {
+ continue
+ }
+ }
+ if err = h.Run(ctx, j.Prior, j.Successor); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+func (h ServingHandover) at(point string) error {
+ if h.checkpoint != nil {
+ return h.checkpoint(point)
+ }
+ return nil
+}
diff --git a/internal/hostbox/servinghandover_linux_test.go b/internal/hostbox/servinghandover_linux_test.go
new file mode 100644
index 000000000..d5fecd0e0
--- /dev/null
+++ b/internal/hostbox/servinghandover_linux_test.go
@@ -0,0 +1,167 @@
+//go:build linux
+
+package hostbox
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func servingHandoverFixture(t *testing.T) (ServingHandover, hostguard.FenceTarget, ColdSuccessor) {
+ t.Helper()
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ source, store, initial := workFixture(t)
+ admit := func(o hostclaim.Observed, g string) hostclaim.Observed {
+ n, err := hostclaim.Update(t.Context(), store, o, hostclaim.AdmitWriter(hostclaim.Admission{ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation, Box: "box", Region: "ie", Mode: "cold", NewGeneration: g}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return n
+ }
+ old := admit(initial, "first")
+ base := t.TempDir()
+ disk := &plan14ColdDisk{dir: t.TempDir()}
+ reg, err := registry.Open(filepath.Join(base, "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { reg.Close() })
+ inv, err := OpenStopInventoryDB(filepath.Join(base, "inventory.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { inv.Close() })
+ launches, err := OpenLaunchJournal(filepath.Join(base, "launch.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { launches.Close() })
+ guard := source.Guard
+ guard.Root = hostguard.NewRootAuthorityWithInventory(disk, func(ctx context.Context, host string, c hostguard.Captured) (uint64, error) {
+ _, r, e := inv.Snapshot(ctx, host, c)
+ return r, e
+ })
+ holder := old.Claim.Holder
+ owner := ownershipFor(old.Host, holder.Incarnation, holder.Stamp, "box")
+ row := registry.Route{Host: old.Host, Kind: "instance", Account: holder.Account, App: holder.App, DBPath: provision.NestedDBPath(guard.DataDir, old.Host), Socket: provision.NestedSocket(old.Host), ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: holder.Incarnation, ClaimStamp: holder.Stamp, ClaimOwner: owner, ClaimRowID: "old-route"}}
+ if err = reg.Upsert(row); err != nil {
+ t.Fatal(err)
+ }
+ dir := filepath.Dir(row.DBPath)
+ if err = os.MkdirAll(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err = os.WriteFile(filepath.Join(dir, "env"), []byte("configuration"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ roots := map[string]string{"data": guard.DataDir}
+ rel, _ := filepath.Rel(guard.DataDir, dir)
+ a, err := (TypedParker{Roots: roots, ParkedRoots: roots, Incarnation: holder.Incarnation, Host: old.Host, Retirement: "capture"}).CaptureArtifact("data", rel, owner, ArtifactPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ in := StopInventory{Complete: true, Host: old.Host, Incarnation: holder.Incarnation, InstallationStamp: holder.Stamp, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1", Entries: []StopInventoryEntry{
+ {Kind: "route", ArtifactID: "old-route", ManagedRoot: "registry", RelativePath: old.Host, RowIdentity: "old-route", Ownership: owner, Materialization: "materialized"},
+ {Kind: "path", ArtifactID: "state", ManagedRoot: "data", RelativePath: rel, Ownership: owner, Materialization: "materialized", Device: a.Dev, Inode: a.Ino, Type: a.Type},
+ }}
+ if err = inv.WriteStopInventory(t.Context(), in); err != nil {
+ t.Fatal(err)
+ }
+ _, rev, err := inv.Snapshot(t.Context(), old.Host, hostguard.Captured{Incarnation: holder.Incarnation, Stamp: holder.Stamp})
+ if err != nil {
+ t.Fatal(err)
+ }
+ installation := hostguard.RootInstallation{Host: old.Host, Incarnation: holder.Incarnation, Stamp: holder.Stamp, Account: holder.Account, App: holder.App, Record: holder.Key, Box: "box", Enrollment: "enrollment", Migration: 1, InventoryRevision: rev}
+ if err = guard.EstablishRoot(t.Context(), installation, func(*hostclaim.Claim) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ prior := hostguard.FenceTarget{FenceID: "stop", Region: "ie", Host: old.Host, Incarnation: holder.Incarnation, Stamp: holder.Stamp, Session: holder.Session, Box: "box", Enrollment: "enrollment", BootID: "boot", ControllerID: "controller", ClaimETag: old.ETag, DirectoryRevision: "1"}
+ if _, err = guard.BeginFence(t.Context(), prior); err != nil {
+ t.Fatal(err)
+ }
+ if err = guard.CompleteFence(t.Context(), prior, []byte("verified stop"), time.Now().UTC(), func(context.Context, hostguard.FenceTarget, []byte, time.Time) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ successor := admit(old, "second")
+ n := successor.Claim.Holder
+ next := ColdSuccessor{Host: old.Host, Incarnation: n.Incarnation, Stamp: n.Stamp, Session: n.Session, Generation: n.Generation, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1", ETag: successor.ETag}
+ h := ServingHandover{ColdHandover: ColdHandover{Guard: guard, Claims: store, Registry: reg, Inventory: inv, Launches: launches, Roots: roots, JournalDir: filepath.Join(base, "handover"), VerifyReceipt: func(_ context.Context, r hostguard.FenceReceipt) error {
+ if r.Target != prior || string(r.Bytes) != "verified stop" {
+ return ErrChanged
+ }
+ return nil
+ }}, Validate: func(_ context.Context, c *hostclaim.Claim, p hostguard.FenceTarget, _ ColdSuccessor, _ StopInventory, i hostguard.RootInstallation) error {
+ if p != prior || i != installation || !reflect.DeepEqual(c, successor.Claim) {
+ return ErrChanged
+ }
+ return nil
+ }}
+ return h, prior, next
+}
+
+func TestServingHandoverRecoversEveryBoundary(t *testing.T) {
+ for _, point := range []string{"intent", "barrier", "inventory", "rows", "complete-journal"} {
+ t.Run(point, func(t *testing.T) {
+ h, p, n := servingHandoverFixture(t)
+ old, _, err := h.Inventory.Snapshot(t.Context(), p.Host, hostguard.Captured{Incarnation: p.Incarnation, Stamp: p.Stamp})
+ if err != nil {
+ t.Fatal(err)
+ }
+ crash := errors.New("injected interruption")
+ h.checkpoint = func(got string) error {
+ if got == point {
+ return crash
+ }
+ return nil
+ }
+ if err = h.Run(t.Context(), p, n); !errors.Is(err, crash) {
+ t.Fatal(err)
+ }
+ _ = h.Guard.Activate(t.Context(), p.Host, p.Box, nil)
+ allowed := func(*hostclaim.Claim) error { return nil }
+ if err = h.Guard.With(t.Context(), p.Host, hostguard.Captured{Incarnation: n.Incarnation, Stamp: n.Stamp}, allowed, func() error { t.Fatal("successor ran during partial handover"); return nil }); err == nil {
+ t.Fatal("partial transfer authorized")
+ }
+ h.checkpoint = nil
+ if err = h.Recover(t.Context()); err != nil {
+ t.Fatal("recover", err)
+ }
+ if err = h.Run(t.Context(), p, n); err != nil {
+ t.Fatal("replay", err)
+ }
+ if err = h.Guard.With(t.Context(), p.Host, hostguard.Captured{Incarnation: n.Incarnation, Stamp: n.Stamp}, allowed, func() error { return nil }); err != nil {
+ t.Fatal("completed transfer", err)
+ }
+ retained, _, err := h.Inventory.Snapshot(t.Context(), p.Host, hostguard.Captured{Incarnation: p.Incarnation, Stamp: p.Stamp})
+ if err != nil || !reflect.DeepEqual(retained, old) {
+ t.Fatal("old inventory", err)
+ }
+ b, err := os.ReadFile(filepath.Join(filepath.Dir(provision.NestedDBPath(h.Guard.DataDir, p.Host)), "env"))
+ if err != nil || string(b) != "configuration" {
+ t.Fatal("source lost", err)
+ }
+ })
+ }
+}
+
+func TestServingHandoverRefusesOldDestructiveRecovery(t *testing.T) {
+ h, p, n := servingHandoverFixture(t)
+ old := h.ColdHandover
+ old.OperationsOnly = true
+ if err := old.Run(t.Context(), p, n); !errors.Is(err, ErrUnsupported) {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Dir(provision.NestedDBPath(h.Guard.DataDir, p.Host))); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/hostguard/coldhandover.go b/internal/hostguard/coldhandover.go
index b477c6f05..8919d1265 100644
--- a/internal/hostguard/coldhandover.go
+++ b/internal/hostguard/coldhandover.go
@@ -6,8 +6,19 @@ import (
)
func (g Guard) BeginColdHandover(ctx context.Context, prior, barrier FenceTarget, check func(*hostclaim.Claim) error) error {
+ return g.beginColdHandover(ctx, prior, barrier, nil, check)
+}
+
+func (g Guard) BeginServingHandover(ctx context.Context, prior, barrier FenceTarget, installation RootInstallation, check func(*hostclaim.Claim) error) error {
+ if g.Root == nil || prior.Op != "" || prior.Phase != "" || prior.ObligationID != "" || barrier.Op != "" || barrier.Phase != "" || barrier.ObligationID != "" || installation.Empty || installation.Host != prior.Host || installation.Incarnation != prior.Incarnation || installation.Stamp != prior.Stamp || installation.Box != prior.Box || installation.Enrollment != prior.Enrollment || installation.InventoryRevision == 0 {
+ return ErrInvalidInput
+ }
+ return g.beginColdHandover(ctx, prior, barrier, &installation, check)
+}
+
+func (g Guard) beginColdHandover(ctx context.Context, prior, barrier FenceTarget, installation *RootInstallation, check func(*hostclaim.Claim) error) error {
return g.locked(ctx, prior.Host, func(d *recordDir) error {
- if prior.validate(prior.Host) != nil || barrier.validate(prior.Host) != nil || prior.Host != barrier.Host || prior.Incarnation != barrier.Incarnation || barrier.Stamp <= prior.Stamp || barrier.Session <= prior.Session || barrier.Stamp != barrier.Session || check == nil {
+ if ((installation == nil || prior.Session != 0) && prior.validate(prior.Host) != nil) || barrier.validate(prior.Host) != nil || prior.Host != barrier.Host || prior.Incarnation != barrier.Incarnation || barrier.Stamp <= prior.Stamp || barrier.Session <= prior.Session || barrier.Stamp != barrier.Session || check == nil {
return ErrInvalidInput
}
claim, err := g.fresh(ctx, prior.Host)
@@ -24,15 +35,21 @@ func (g Guard) BeginColdHandover(ctx context.Context, prior, barrier FenceTarget
if err != nil {
return err
}
+ if installation != nil && (r.installation != *installation || r.quarantined || r.pendingInstallation != nil) {
+ return ErrRecordIncarnation
+ }
found := false
for _, receipt := range r.FenceJournal {
if receipt.Target == prior {
found = true
}
}
- if !found {
+ if !found && (installation == nil || prior.Session != 0) {
return ErrNoFenceReceipt
}
+ if installation != nil && prior.Session == 0 && (prior.FenceID != "" || (r.ActiveFence != nil && *r.ActiveFence != barrier)) {
+ return ErrFenceTarget
+ }
if r.ActiveFence != nil && *r.ActiveFence == barrier {
return d.sync(prior.Host)
}
diff --git a/internal/provision/testdata/plan14-root-reads-seed.tsv b/internal/provision/testdata/plan14-root-reads-seed.tsv
index 169040101..84e757af5 100644
--- a/internal/provision/testdata/plan14-root-reads-seed.tsv
+++ b/internal/provision/testdata/plan14-root-reads-seed.tsv
@@ -1,5 +1,5 @@
-# Files seed checkpoint 9 overlay; pending Paul’s census ruling.
-# Generation, legacy and empty-bootstrap root authority have test callers only; production binding remains refused.
+# Files seed checkpoint 11 overlay; pending Paul’s census ruling.
+# Database retirement activation remains refused; serving installation transfer uses the existing root transport.
# Load after zddr4 and its explicit seal check. No legacy fingerprints are added or removed.
# Every classification below is rev3=PROPOSED-seed, not an approved disposition.
internal/host/claimconstruction.go OpenProductionClaimAssembly authority C,G rev3=PROPOSED-seed; box process identity and provider bindings; serving dispatch independent of moves; serving enrollment required before a runtime can be built file:Read@4150,file:Read@9550,file:ReadFile@3694,registry:Host@9577,whole:Route@9665 088f34c7fe1b4f9bb7a3187d40d60bff0fd811ebf1e1888ce3d4781dc4de80d7
@@ -68,16 +68,16 @@ internal/host/claimservingpark_records.go validateServingParkRecord authority C,
internal/host/claimservingparksource_linux.go servingParkStateDirectory authority C,J4 rev3=PROPOSED-seed; root installation/inventory snapshots and authoritative serving observation; exact account/app/record, revision/enrollment, canonical nested path, ownership and descriptor identity; root acquires these inputs under the host lock through the typed journal service ec4a121330c5c8f27343f21af0bbe458cced885d4754e96c0d87b12d9b4a8f35
internal/hostguard/servingretirement.go Guard.WithServingRetirement authority C,J4 rev3=PROPOSED-seed; fresh serving claim and root installation/fence snapshot under root and shared host locks; refuses pending, quarantined, foreign, retired or already stopped successors; permits an older completed predecessor 3d105c50be7e613777055f561321efadf77fe823d8dc3263a60f2c1211b71108
internal/host/claimservingparkroot_linux.go servingParkRoot.withAuthority authority C,J4 rev3=PROPOSED-seed; live edge peer reauthentication under namespace and host locks before census, inventory and final claim checks; root request entry refuses nil connections d3582f0760b4459721b3e6040cda738b105c1da19cc5d0746d04fea26e313b5c
-internal/host/claimservingparkroot_linux.go servingRootStops authority C,J4 rev3=PROPOSED-seed; root fence snapshot and known installation stamps; exact local serving receipt bytes, enrollment, revision and unique fence identities d16eb077adfb8f0f8c9e63d1609250dc3470ec424bdde63b57c47d09500b2bce
+internal/host/claimservingparkroot_linux.go servingRootStops authority C,J4 rev3=PROPOSED-seed; retained exact root serving receipts and known retired installation; same enrollment, session and region, with canonical recorded revision no newer than the active actor d371f01614c0bb64d3f412123fc9a90b8bee43d02b6c48e67fc89d9a35187005
internal/host/claimservingparkroot_linux.go servingParkRoot.parkServingDatabase authority C,J4 rev3=PROPOSED-seed; fresh paired tags, root slot/record and retained admissions; recorded legacy work dispatches to separately locked fresh legacy authority 61c29d5b37168ab053499553eaf0052fcee134a601c86acb3195fad416d28247
internal/host/claimservingparkroot_linux.go servingParkRoot.completeServingDatabasePark authority C,J4 rev3=PROPOSED-seed; fresh paired enrollment, same current writer, root source and original/continuation stop history; legacy additionally authenticates persistent no-process or admitted predecessor evidence 8ca9bdd3c1209c6d5257bc07a7d12d0bab900e9c2911d0698077eb2116e5123c
internal/host/claimservingparkroot_linux.go validateServingRootHistory authority C,J4 rev3=PROPOSED-seed; retained local successor admissions and byte-exact root stops for every persisted continuation; rejects relabelled or missing history c85a1be96de5fa61acd48c4adf9393d8e837880f5133a2f965f1104037e02329
internal/host/claimservingpark_records.go servingParkRecord.activeAuthorization authority C,J4 rev3=PROPOSED-seed; last persisted continuation authorization or immutable original authorization; no source or deadline changes fe168fe52317e45ce70c3e97a9bc2c52d2409328ab4844a8d2a34fc4babba36d
internal/host/claimservingpark_records.go servingParkLedger.continueLocked authority C,J4 rev3=PROPOSED-seed; namespace/host-protected slot and record locks; pending original retirement, absent source and retained parked identities before durable bounded continuation 87f297a924f7f153624304233218d8b5548a016fc7dbf0f7cb8dedb789324009
internal/host/claimservingpark_records.go servingParkLedger.replaceLocked authority C,J4 rev3=PROPOSED-seed; completed incumbent record under its record lock; resumes its recorded deletion cause before slot replacement 3e67316e959c9a901c32a2a61fda4e6ea5e92fe51bdb7fdb5f5c53d9d5960aad
-@coverage internal/host c10497dab1ac3aef334df090b73e4d20fcc67d43a31dd4d457ead6f4f61de83f
-@coverage internal/hostbox 4b68d7fc25b29963dcf1b7fe92b047fd2229515d83e5cf424c0835d6e690859d
-@coverage internal/hostguard 94912485e1e5fad4bad56985dd48ede776cfcb5fe18e0f4a59953584ddab06c0
+@coverage internal/host 8b2636a4c0d76b826c438e30b4a433ebf2ce5195e9d6aa21980b72a9bf84caa3
+@coverage internal/hostbox eb1f4980ba609cc886ae4bd73aedbb6be3fb2eb104165bb1d8404da0814ed190
+@coverage internal/hostguard a721142f4f3ac5fa17b6b1e2ccfc66ca809cfdcbf2733fdc00fbd0efaa90aeb6
internal/host/claimservinglegacy.go servingLegacyKey authority C,J4 rev3=PROPOSED-seed; box, host and incarnation derive private stop and consumption keys; never a caller path 93c10e40202ab23986b54a366edd7abfab9f8d942736305b5732e8741e96f3ab
internal/host/claimservinglegacy.go validateServingLegacyStop authority C,J4 rev3=PROPOSED-seed; original session-zero serving observation and exact root no-process bytes; claim ETag, receipt digest, kernel evidence and empty fence identity file:Exec@413 88bf4d578819b8d3f62b80e2068a4c574a1aaa8118a8461bbeee99569e035ed0
internal/host/claimservinglegacy.go servingParkLedger.legacyStop authority C,J4 rev3=PROPOSED-seed; strict private retained no-process record and exact host, box, region and incarnation; no bucket or caller evidence file:Read@131 3cc2122431a095406bcd11a1a6ace825a870f5f8d0b8053d2b27486084838c4f
@@ -86,7 +86,7 @@ internal/host/claimservinglegacy.go sameServingLegacyEvidence authority C,J4 rev
internal/host/claimservinglegacy.go validateServingLegacyEvidence authority C,J4 rev3=PROPOSED-seed; original successor, legacy selector and original session-zero holder; first-creator status is not a request assertion 485ab1232cc0817ad52a638bb220fe532c5a16086379e689b1c27dea6948f6b5
internal/host/claimservinglegacy.go servingParkLedger.legacyUnlaunched authority C,J4 rev3=PROPOSED-seed; local root launch history for current successor; any main-role intention refuses legacy source capture and completion 1963414899b4ce2bb059950bc6cfdad4e8c5ea0a575d2e19f87c91a4cf82c8ad
internal/host/claimservinglegacyroot_linux.go servingParkRoot.retainLegacyNoProcess authority C,J4 rev3=PROPOSED-seed; root coordinator result before publication; namespace and host guards, census, roster, fresh original claim and immutable retained stop; repeated preadmission witnesses tolerate only Gen, ETag and retry metadata changes file:Read@1210 5460530ae4367720515089e9fda3105aa1032b8282f4923bbf7f8af9600f4f63
-internal/host/claimservinglegacyroot_linux.go servingParkRoot.validateLegacyRootEvidence authority C,J4 rev3=PROPOSED-seed; retained original observation, first cold admission and writes-lost identity or exact admitted-predecessor root stops; enrollment and revision are rechecked 8ebc1e7841b8b8cfabc8e4f3520ef13d40130e68aa7ded608b829063e3d3f89a
+internal/host/claimservinglegacyroot_linux.go servingParkRoot.validateLegacyRootEvidence authority C,J4 rev3=PROPOSED-seed; retained no-process or stopped-admission evidence; exact predecessor fields with canonical recorded directory revision bounded by the current actor aa0dfe36c042ab65fd826e4b83eb81bfb9d03e6a54daafa828d5164492329d0d
internal/host/claimservinglegacyroot_linux.go servingParkRoot.parkLegacyServingDatabase authority C,J4 rev3=PROPOSED-seed; current root authority, persistent consumption, immutable original stop/source and continuation stops; production first-creator empty listing and final claim recheck precede source capture file:Read@3463 4a883d8cc15d58a9a9572d08790173773f35afd46b09caaf9ac99c9ec3a39d1c
internal/host/claimservinglegacyroot_linux.go sameServingLegacySource authority C,J4 rev3=PROPOSED-seed; immutable directory and artifact identities from consumed source and retirement; mutable rename/delete progress is excluded 925e4f20aee042895a83ea3787f4482ae732b6c6bcd45d9d10226ff87bd37091
internal/host/claimservingpark_records.go servingParkLedger.parkWithEvidenceLocked authority C,J4 rev3=PROPOSED-seed; root source identities, exact stop bytes, launch disposition and optional session-zero evidence; persistent consumption precedes intent/rename, empty creators refuse all local artifacts file:Prepare@3632,file:Read@1957 257d2c2674dd3a781f6b1ec842d65128a8e9f3d03b5468cbf6f487c37b45e94f
@@ -122,7 +122,7 @@ internal/hostbox/servingpark_transport.go validateServingParkRequest authority C
internal/host/claimservingpark_transport.go var openClaimServingParkAuthority authority C,J4 rev3=PROPOSED-seed; descriptor-relative private rootstate at /var/lib/carlos-claims/authority/serving-parks with creation enabled and public reads disabled file:Open@78 7a1db088a7240e6a65ba0bed96fc5f973a69f70c2273c4475c486a6e0359fb23
internal/host/claimwakestop.go ClaimAssembly.collectCapturedStop authority C,G,J4 rev3=PROPOSED-seed; fresh claim and verified local stop identity; session-zero legacy holder may name only the local writer box, with no admissions; root independently proves and retains no-process evidence file:Exec@2994,file:Read@605 d4cd80663dfae5f9737a74e319d3423f4ef853e1843666b02b4cd2341e869d72
internal/hostbox/restore.go loadPinned authority C,J2,J4 rev3=PROPOSED-seed; exact selected LTX versions and original trailer/pin proof; validated bytes normalized for Litestream while retaining original database checksum for final comparison file:ReadAll@662 1e2b22c7c501d31cd1f2cb3be0fe419fa7e25afde87c6ecb43ca85edb7dbc3a4
-internal/host/claimretire_transport.go ClaimRootHTTP.RecoverPending authority C,J4 rev3=PROPOSED-seed; root startup recovery order; attempts bounded serving retirement recovery even when another root recovery returns an error 0e6c92aae15cac7055607fb24d0e9044223c74933767b2859ed105e49974e006
+internal/host/claimretire_transport.go ClaimRootHTTP.RecoverPending authority C,J4 rev3=PROPOSED-seed; startup serving handover recovery before ordinary cold and root recovery; old destructive serving intents refuse 627b40daecf11e1a6a6c9f92aeb3ebcf226131da6dfa584ed85559c8b9b5ff30
internal/host/claimservingparkroot_linux.go servingParkRoot.withRootInstallation authority C,J4 rev3=PROPOSED-seed; fresh claim, root installation and directory inventory, eligible enrollment, paired tags, census gate and post-input claim reread under namespace then host guard; startup grants no new identity file:Read@2113 8060d3a2628f86257c3600c986242e0d4957ee918214a89655af828dd3ddf93b
internal/host/claimservingpark_linux.go servingParkFS.removeEvent authority C,J4 rev3=PROPOSED-seed; recorded empty event directory identity and absence; journaled removal intent before descriptor-relative rmdir, root parent fsync and durable progress; no recursion file:Readdirnames@789 b493696d2f16425ddc2714184b407acbaf13ef62c93f28c3d4ac8dc7012607c9
internal/host/claimservingpark_linux.go servingParkFS.removedEvent authority C,J4 rev3=PROPOSED-seed; recorded namespace parent and absence of previously removed event; replacement names refuse completion 792218fc7bfb4ac7475e7c694f72489812db8591493218f8ee1c9545cb813566
@@ -137,3 +137,20 @@ internal/host/claimservingpark_recovery.go servingParkRuntime.recoverPending aut
internal/host/claimservingpark_recovery_linux.go sameServingLegacyConsumption authority C,J4 rev3=PROPOSED-seed; immutable legacy consumption and retained retirement source identities; excludes only per-artifact journal progress from comparison 1524b2aae315e05734a666f80596bc341a007098a84fedeaf1e32451663d4fd4
internal/host/claimservingpark_recovery_linux.go servingParkRoot.recoverServingPark authority C,J4 rev3=PROPOSED-seed; retained intent, exact current successor, independently rechecked installation, enrollment, receipt and local launch history; resumes original park and completes only existing paired tags; no retry settlement 0839c879792469648ceb62edce21319050351cf4db0a57c1a5220282afb7808f
internal/host/claimservingpark_recovery_linux.go servingParkRoot.recoverServingParks authority C,J4 rev3=PROPOSED-seed; private record page and per-record validation; continues past corrupt or refused hosts and reports incomplete enumeration; completed deletion separated from live host authority c2f1d9da88c4aaea97783a6d3f3e6b78856f20402e55900cc96d88e8c88ba323
+internal/host/claimservinghandover.go ClaimRootHTTP.servingHandover authority C,J4 rev3=PROPOSED-seed; root-only serving transfer constructor and existing private journal, inventory and registry dependencies 213a1885dd9aa5d0079cd52c4f88c6893ea5b43bfbfd83a67ec2c4697a83265a
+internal/host/claimservinghandover.go ClaimRootHTTP.validateServingHandover authority C,J4 rev3=PROPOSED-seed; fresh actor/census, claim, canonical deployment record, root installation and predecessor admission; exact retained session-zero no-process evidence or stopped serving predecessor file:Read@2660,file:Read@3770,registry:ClaimIncarnation@1500,registry:ClaimOwner@1642,registry:ClaimRowID@1664,registry:ClaimStamp@1528,whole:Route@1767 10a3ea792139e8328eec6292e9612d0f3d84c2a8500f8959ede3b92230a00788
+internal/host/claimservinghandover.go ClaimRootHTTP.servingLegacyHandoverTarget authority C,J4 rev3=PROPOSED-seed; root-private no-process record selected by host/box/incarnation; only its retained exact stop tuple supplies legacy transfer authority file:Read@524 3fbec96fa13bd112c83253221e808a80943118b1dc06b6b5bcbbeb41ac5771d9
+internal/host/claimhandover_transport.go ClaimRootHTTP.serveColdHandover authority C,J4 rev3=PROPOSED-seed; authenticated existing handover transport; serving-specific transfer and repeated peer identity check under the host lock; operation handover remains separate 9ea7b9ea4a4962ad92a5f6ce19a24fcffca73e40aae9318a2d8c79ef25351e6a
+internal/host/claimhandover_transport.go coldAdmissionHook authority C,J4 rev3=PROPOSED-seed; serving predecessor receipt or session-zero identity selector; root supplies the retained no-process result 70974148b3f47e4cb2057e4000c472cc4b372707fb1ff64b5e62582dc92b70c2
+internal/hostbox/coldhandover.go type ColdHandover authority C,J4 rev3=PROPOSED-seed; operation-only production cold-handover policy; no source-preserving serving authority from operation cleanup 874b2b74ea349dd93655d4e11f4d9e97752c3f216593ebb922d819caba7981c4
+internal/hostbox/coldhandover.go type coldJournal authority C,J4 rev3=PROPOSED-seed; additive root installation snapshot for serving transfer; retained predecessor inventory, receipt, row contents and exact successor 2f5e48fec1ec2b6f1774f905fc27f8c8d0b33605f260e1ea483cbf55ace9b53e
+internal/hostbox/coldhandover.go ColdHandover.Run authority C,J4 rev3=PROPOSED-seed; production operation-only gate refuses destructive serving handover, including old pending journals; existing move effects unchanged file:Exec@1027,file:Lstat@9838,file:OpenRoot@9760,file:Read@1379,registry:Addr@3654,registry:Addrs@3672,registry:Backing@3691,registry:Canary@3748,registry:ClaimIncarnation@3532,registry:ClaimIncarnation@4034,registry:ClaimOwner@10588,registry:ClaimOwner@4116,registry:ClaimOwner@4136,registry:ClaimOwner@6011,registry:ClaimOwner@7439,registry:ClaimOwnership@10180,registry:ClaimOwnership@3502,registry:ClaimOwnership@4480,registry:ClaimOwnership@4698,registry:ClaimOwnership@4731,registry:ClaimRowID@10478,registry:ClaimRowID@10559,registry:ClaimRowID@4154,registry:ClaimRowID@4762,registry:ClaimRowID@7464,registry:ClaimStamp@3577,registry:ClaimStamp@4081,registry:Host@7401,registry:Kind@3610,registry:Name@4816,registry:Name@7420,registry:Platform@3732,registry:Registration@4180,registry:Registration@7489,registry:Replica@3762,registry:Route@10180,registry:Route@4480,registry:Route@4731,registry:Route@5262,registry:Route@6011,registry:Timers@4679,registry:Timers@4698,registry:Timers@4762,registry:Timers@4816,registry:Timers@7276,registry:Tunnel@3712,registry:Unit@3636,whole:ClaimOwnership@10194,whole:ClaimOwnership@3506,whole:ClaimOwnership@4492,whole:ClaimOwnership@4714,whole:ClaimOwnership@4743,whole:ColdRows@10182,whole:ColdRows@11294,whole:ColdRows@11304,whole:ColdRows@4977,whole:ColdRows@4985,whole:ColdRows@4993,whole:ColdRows@5000,whole:ColdRows@5264,whole:ColdRows@6013,whole:ColdRows@7278,whole:Route@10188,whole:Route@3487,whole:Route@3847,whole:Route@4279,whole:Route@4287,whole:Route@4338,whole:Route@4346,whole:Route@4486,whole:Route@4737,whole:Route@5271,whole:Route@6020,whole:Timer@4018,whole:Timer@4292,whole:Timer@4300,whole:Timer@4351,whole:Timer@4389,whole:Timer@4685,whole:Timer@4704,whole:Timer@4768,whole:Timer@4822,whole:Timer@7285 050ae1e6818687e19e7a0cd443c09e837e54bdc00f4ea1feb6430cfedd2b5343
+internal/hostbox/servinghandover.go type ServingHandover authority C,J4 rev3=PROPOSED-seed; root-only serving transfer dependencies and validation callback; source state remains in place d56e1f341efd969be8757c2a2440170192d30338095c28ee9cba337de7a81bd6
+internal/hostbox/servinghandover.go ServingHandover.Run authority C,J4 rev3=PROPOSED-seed; root installation/inventory snapshot, exact stopped predecessor, canonical nested row, no-follow state identity, successor launch absence and private journal; fenced inventory/row transfer with retained old evidence registry:Account@2154,registry:Addr@2373,registry:Addrs@2391,registry:App@2198,registry:Backing@2410,registry:Canary@2467,registry:ClaimOwner@5480,registry:ClaimOwnership@2841,registry:ClaimOwnership@3057,registry:ClaimOwnership@3090,registry:ClaimRowID@3120,registry:ClaimRowID@5551,registry:ClaimRowID@5725,registry:DBPath@2286,registry:Kind@2128,registry:Name@3174,registry:Name@5690,registry:Platform@2451,registry:Replica@2481,registry:Route@2841,registry:Route@3090,registry:Route@5480,registry:Route@5551,registry:Socket@2234,registry:Timers@3039,registry:Timers@3057,registry:Timers@3120,registry:Timers@3174,registry:Timers@5633,registry:Tunnel@2431,registry:Unit@2355,whole:ClaimOwnership@2853,whole:ClaimOwnership@3073,whole:ClaimOwnership@3102,whole:ColdRows@3514,whole:ColdRows@3522,whole:ColdRows@3530,whole:ColdRows@3537,whole:ColdRows@5482,whole:ColdRows@5553,whole:ColdRows@5635,whole:ColdRows@6216,whole:ColdRows@6226,whole:Route@2114,whole:Route@2643,whole:Route@2651,whole:Route@2701,whole:Route@2709,whole:Route@2847,whole:Route@3096,whole:Route@5488,whole:Route@5559,whole:Timer@2656,whole:Timer@2664,whole:Timer@2714,whole:Timer@2752,whole:Timer@3045,whole:Timer@3063,whole:Timer@3126,whole:Timer@3180,whole:Timer@5641 01b9e603a5f2a2476017777a7d02fafc76424d491201d6757170d66997fa481a
+internal/hostbox/servinghandover.go ServingHandover.validate authority C,J4 rev3=PROPOSED-seed; fresh serving claim, unchanged root-recorded inventory, exact recorded state-directory inode/type, complete route/timer set and production authority callback file:Exec@258,registry:ClaimIncarnation@2164,registry:ClaimOwner@1379,registry:ClaimOwner@2238,registry:ClaimOwnership@2132,registry:ClaimRowID@2273,registry:ClaimStamp@2207,registry:Name@2118,registry:Registration@2310,registry:Route@1033,registry:Route@1379,registry:Timers@2064,registry:Timers@2507,whole:ClaimOwnership@2138,whole:ColdRows@1035,whole:ColdRows@1381,whole:ColdRows@2066,whole:ColdRows@2509,whole:Route@1042,whole:Route@1388,whole:Timer@2073,whole:Timer@2516 2985e74f1c0aed6a6470e4ee4f03e794e32a97806542a51045a21e76ba7bce03
+internal/hostbox/servinghandover.go ServingHandover.Recover authority C,J4 rev3=PROPOSED-seed; trusted serving transfer journals and pending root barriers; replay exact original transfer before prelaunch 7ec3bd1b929ca3b3fd7d20c53437740d54086fbb2ae1fc8c545dbca3e388bab1
+internal/hostbox/servinghandover.go ServingHandover.at authority C,J4 rev3=PROPOSED-seed; in-process interruption seam; nil in production 0178e5fcfb2cc194fb4f33f0f230b52849ddeb87a4b7c4e6cfb640e8b2f00b8a
+internal/hostguard/coldhandover.go Guard.BeginColdHandover authority C,J4 rev3=PROPOSED-seed; unchanged operation handover checks delegated to shared locked implementation f06625d82015893692dce315f8014a7f44d65de69f681a5102f418e9772b875e
+internal/hostguard/coldhandover.go Guard.BeginServingHandover authority C,J4 rev3=PROPOSED-seed; serving-only root installation and empty-operation checks; session-zero authorization remains in root callback bd953e2794294c5638161eff30fd743000fbd467d6ea37df734668021d8f6c02
+internal/hostguard/coldhandover.go Guard.beginColdHandover authority C,J4 rev3=PROPOSED-seed; fresh claim and root installation under host locks; exact completed stop for admitted predecessors, validated no-process path for session zero, and durable successor barrier 6bdeec90f4322aa0824557cd35950af428b25889e15badeb4b017ccbbf0aae70
+internal/host/claimservinghandover.go servingPriorRevision authority C,J4 rev3=PROPOSED-seed; canonical recorded directory revision bounded by the current authenticated enrollment revision; never rewrites retained stop evidence 8007e188e2ad600cf36d8eed0fb5adb591f08f52be52285c85db348e0780cb84