carlos / platform Public

WIP files seed (#250 C8) checkpoint 1 (not for merge): claimed serving built independently of moves, restore hooks, receipt groundwork

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 7956ea1bde149ebc74265e79c73675ec87d7960e parent ebddcfc
14 files changed, +646 −45
  • internal/host/claimconstruction.go +16 −10
  • internal/host/claimfilesseed_assembly_linux_test.go +199 −0
  • internal/host/claimfilesseed_enrollment_test.go +40 −0
  • internal/host/claimowners.go +11 −1
  • internal/host/claimproduction.go +6 −13
  • internal/host/claimwakeassembly.go +39 −15
  • internal/host/plan9_i2_movesoff_test.go +4 −6
  • internal/hostbox/restore.go +26 −0
  • internal/hostbox/restore_enrollment_test.go +109 −0
  • internal/hostguard/servingstop_test.go +103 −0
  • internal/hostguard/writerstop.go +33 −0
  • internal/provision/plan14_legacy_identity_test.go +39 −0
  • internal/provision/plan14_root_reads_test.go +6 −0
  • internal/provision/testdata/plan14-root-reads-seed.tsv +15 −0

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	331.423s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.134s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.273s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.733s
ok  	github.com/carlosframework/platform/internal/activator	235.896s
ok  	github.com/carlosframework/platform/internal/agentsock	0.017s
ok  	github.com/carlosframework/platform/internal/alerting	0.005s
ok  	github.com/carlosframework/platform/internal/analytics	0.020s
ok  	github.com/carlosframework/platform/internal/appconfig	2.335s
ok  	github.com/carlosframework/platform/internal/apperr	0.082s
ok  	github.com/carlosframework/platform/internal/applog	0.144s
ok  	github.com/carlosframework/platform/internal/awsinv	0.004s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.005s
ok  	github.com/carlosframework/platform/internal/boxid	0.999s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.009s
ok  	github.com/carlosframework/platform/internal/caps	0.011s
ok  	github.com/carlosframework/platform/internal/certwarm	0.003s
ok  	github.com/carlosframework/platform/internal/cgroup	0.698s
ok  	github.com/carlosframework/platform/internal/changelog	0.297s
ok  	github.com/carlosframework/platform/internal/clamd	0.054s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	361.803s
?   	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.244s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.002s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.114s
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.253s
?   	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.121s
ok  	github.com/carlosframework/platform/internal/gate	1.655s
ok  	github.com/carlosframework/platform/internal/genselect	0.015s
ok  	github.com/carlosframework/platform/internal/geo	0.013s
ok  	github.com/carlosframework/platform/internal/geoip	0.031s
ok  	github.com/carlosframework/platform/internal/host	128.862s
ok  	github.com/carlosframework/platform/internal/hostactors	0.245s
ok  	github.com/carlosframework/platform/internal/hostbox	2.129s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.531s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.010s
ok  	github.com/carlosframework/platform/internal/hostexec	4.864s
ok  	github.com/carlosframework/platform/internal/hostfence	0.034s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.605s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.014s
ok  	github.com/carlosframework/platform/internal/hostguard	1.013s
ok  	github.com/carlosframework/platform/internal/hostops	1.395s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.010s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.009s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.446s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.140s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.009s
ok  	github.com/carlosframework/platform/internal/infraview	0.009s
ok  	github.com/carlosframework/platform/internal/instancectl	1.820s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.709s
ok  	github.com/carlosframework/platform/internal/instancefs	0.009s
ok  	github.com/carlosframework/platform/internal/instancekey	0.011s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.016s
ok  	github.com/carlosframework/platform/internal/layout	1.434s
ok  	github.com/carlosframework/platform/internal/lease	9.446s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.014s
ok  	github.com/carlosframework/platform/internal/ledger	0.013s
ok  	github.com/carlosframework/platform/internal/mail	0.171s
ok  	github.com/carlosframework/platform/internal/maintenance	0.093s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.504s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.071s
ok  	github.com/carlosframework/platform/internal/metrics	0.004s
ok  	github.com/carlosframework/platform/internal/moves	21.060s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.763s
ok  	github.com/carlosframework/platform/internal/objstore	0.164s
ok  	github.com/carlosframework/platform/internal/ops	0.160s
ok  	github.com/carlosframework/platform/internal/park	0.215s
ok  	github.com/carlosframework/platform/internal/privatefile	0.003s
ok  	github.com/carlosframework/platform/internal/provision	13.205s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.006s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	4.035s
ok  	github.com/carlosframework/platform/internal/relay	7.235s
ok  	github.com/carlosframework/platform/internal/release	19.730s
ok  	github.com/carlosframework/platform/internal/rollout	3.764s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.007s
ok  	github.com/carlosframework/platform/internal/rootstate	0.025s
ok  	github.com/carlosframework/platform/internal/s3compat	0.004s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.306s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.473s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.239s
ok  	github.com/carlosframework/platform/internal/status	0.038s
ok  	github.com/carlosframework/platform/internal/steering	0.005s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.194s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.002s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.001s
ok  	github.com/carlosframework/platform/internal/vet	1.570s
=== 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: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
  minio: downloaded, digest verified
  mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
  mc: downloaded, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.022s
=== RUN   TestT4StorageProbeMinio
2026/10/14 05:57:23 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE4FCCF4A15DDF, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.020s
minio contract: PASS
diff --git a/internal/host/claimconstruction.go b/internal/host/claimconstruction.go
index b347bacfa..3989ada12 100644
--- a/internal/host/claimconstruction.go
+++ b/internal/host/claimconstruction.go
@@ -58,7 +58,8 @@ type ClaimOwnerBindings struct {
Local hostbox.LocalAuthority
Controllers StopControllerReader
Data lease.VersionedStore
- // Legacy owners are used unchanged unless both feature flags enable Group D.
+ // Claims-off callers keep the legacy owners. Claimed serving has its own
+ // bindings; the moves flag extends that dispatch with operation owners.
Inputs ClaimWakeInputs
AdmissionEvidence func(context.Context, hostclaim.Observed, hostclaim.Admission, *genselect.Plan) error
PublicationEvidence func(context.Context, hostclaim.Observed) error
@@ -162,9 +163,10 @@ func OpenProductionClaimAssembly(ctx context.Context, store release.Store, r Cla
if owners.Local == nil || owners.Controllers == nil || owners.Data == nil || owners.InputPolicy == nil || owners.MovesPolicy == nil || owners.Evidence == nil || owners.LeaseSet == nil || owners.View == nil || owners.Authorize == nil {
return nil, hostwrite.ErrUnavailable
}
- if movesC1 {
- if owners.Serving.Launch == nil || owners.Serving.Markers == nil {
- return nil, hostwrite.ErrUnavailable
+ servingEnabled := hostwrite.Enabled(claimEntryEnv(ctx))
+ if servingEnabled {
+ if err := checkClaimServingSources(owners.Serving); err != nil {
+ owners.wakeUnavailable = errors.Join(owners.wakeUnavailable, err)
}
} else if owners.Inputs == nil || owners.AdmissionEvidence == nil || owners.PublicationEvidence == nil {
return nil, hostwrite.ErrUnavailable
@@ -280,17 +282,21 @@ func OpenProductionClaimAssembly(ctx context.Context, store release.Store, r Cla
var serving *claimServingOwners
var publisher ClaimFencedPublication
var groupD claimGroupD
- if movesC1 {
+ if servingEnabled {
serving = &claimServingOwners{HomeRegion: actor.HomeRegion, Data: hostbox.RequestStore{Store: owners.Data, Check: func(ctx context.Context) error { _, err := owners.Identity.Verify(ctx); return err }}, Bucket: actor.Bucket, Region: park.PlacementRegion(actor.Region), Box: actor.Box, NewID: newID, Now: time.Now, Sources: owners.Serving}
- groupD, err = claimGroupDWiring(claimEntryEnv(ctx), serving.owners(), owners.Moves, owners.Relocation)
- if err != nil {
- return nil, err
+ dispatch := ClaimOwnerDispatch{Serving: serving.owners()}
+ if movesC1 {
+ groupD, err = claimGroupDWiring(claimEntryEnv(ctx), serving.owners(), owners.Moves, owners.Relocation)
+ if err != nil {
+ return nil, err
+ }
+ dispatch = groupD.Dispatch
}
- dispatch := groupD.Dispatch
inputs, admissionEvidence, publicationEvidence = dispatch, dispatch.AdmissionEvidence, dispatch.PublicationEvidence
publisher = dispatch
}
wake := &ClaimWakeAssembly{Manager: r.Manager, Inputs: inputs, LeaseRegion: actor.Cloud.Region, Publication: publisher}
+ wake.RequireServingEnrollment = servingEnabled
wake.Producers = func(ctx context.Context, o hostclaim.Observed) (*ClaimProducers, error) {
if o.Claim == nil || o.Claim.Holder == nil || controller == "" {
return nil, hostwrite.ErrUnavailable
@@ -353,7 +359,7 @@ func OpenProductionClaimAssembly(ctx context.Context, store release.Store, r Cla
digest := sha256.Sum256(raw)
return hex.EncodeToString(digest[:]), nil
}
- if movesC1 {
+ if servingEnabled {
wake.Retries.Inputs = serving.launchDigest
}
// ClaimAssembly.Build binds Retries.StopAttempt to the attempt's persisted
diff --git a/internal/host/claimfilesseed_assembly_linux_test.go b/internal/host/claimfilesseed_assembly_linux_test.go
new file mode 100644
index 000000000..6d3adc7f5
--- /dev/null
+++ b/internal/host/claimfilesseed_assembly_linux_test.go
@@ -0,0 +1,199 @@
+//go:build linux
+
+package host
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/lease/leasetest"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestClaimServingAssemblyWithoutMoves(t *testing.T) {
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "1")
+ t.Setenv("CARLOS_MOVES_PHASE1", "")
+ for _, ha := range []string{"", "1"} {
+ t.Run("region-ha="+ha, func(t *testing.T) {
+ t.Setenv("CARLOS_REGION_HA", ha)
+ store, resources, owners, _ := fix4JournalFixture(t)
+ ctx, err := claimEntryContext(t.Context(), os.Getenv, func(context.Context) (release.Store, error) { return store, nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ actor, err := owners.Identity.Verify(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ data := leasetest.NewVersionedBucket()
+ data.AddBucket(actor.Bucket)
+ rt := registry.Route{Host: filesHost, Kind: "instance", Hibernate: true, DBPath: provision.NestedDBPath(resources.DataDir, filesHost), Socket: provision.NestedSocket(filesHost)}
+ dir := filepath.Dir(rt.DBPath)
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, filesHost+".env"), []byte("K=1\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ source := &movesFilesSource{DataDir: resources.DataDir, Routes: testClaimRoutes{rows: map[string]registry.Route{filesHost: rt}}, newFiles: func(context.Context, string, string) (release.Store, error) {
+ return casBucketStore{b: data, bucket: actor.Bucket}, nil
+ }}
+ files, err := source.Open(ctx, store, data, actor)
+ if err != nil {
+ t.Fatal(err)
+ }
+ d5 := plan9D5Fixture(t)
+ owners.Data = data
+ owners.Serving = d5.owners.Sources
+ owners.Serving.Files = files
+ owners.Serving.Markers = plan9D5Markers{data}
+ assembly, err := OpenProductionClaimAssembly(ctx, store, resources, owners)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer assembly.Close(ctx)
+ if assembly.wakeUnavailable != nil {
+ t.Fatal(assembly.wakeUnavailable)
+ }
+ dispatch, ok := assembly.Wake.Inputs.(ClaimOwnerDispatch)
+ if !ok || dispatch.Moves != nil || dispatch.Relocation != nil || assembly.Wake.Operations != nil || assembly.Wake.ProbeReady != nil {
+ t.Fatalf("serving-only assembly: %#v", assembly.Wake)
+ }
+ if digest, err := assembly.Wake.Retries.Inputs(ctx, rt); err != nil || digest == "" {
+ t.Fatalf("digest %q: %v", digest, err)
+ }
+ o, err := hostclaim.Read(ctx, store, filesHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ n := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + filesHost + ".json", Account: "bab", App: "jam", Host: filesHost, Region: actor.Region, CreatedAt: filesCreated}
+ o, err = hostclaim.Update(ctx, store, o, hostclaim.BeginCreate(n, []byte(`{"host":"shop.example.com","account":"bab","app":"jam","created_at":"2026-10-01T00:00:00Z"}`), "create", "console", time.Now()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err = hostclaim.Update(ctx, store, o, hostclaim.CompleteCreate("create", actor.Box))
+ if err != nil {
+ t.Fatal(err)
+ }
+ in, err := dispatch.Select(ctx, rt, o, recoveryPolicy)
+ if err != nil || !in.Empty {
+ t.Fatalf("empty selection: %+v %v", in, err)
+ }
+ writer := hostbox.Writer{Claims: store, Box: actor.Box, Region: actor.Region, AdmissionEvidence: assembly.AdmissionEvidence}
+ admitted, err := writer.Admit(ctx, o, in.Admission, in.Baseline)
+ if err != nil {
+ t.Fatal(err)
+ }
+ work := &filesFixture{t: t}
+ restore := hostbox.ColdRestore{Generation: in.Admission.NewGeneration, Work: work, Identity: in.Identity}
+ if err := restore.InstallEmpty(ctx, rt.DBPath, func(ctx context.Context) error { return dispatch.EmptyAuthority(ctx, admitted, in) }); err != nil {
+ t.Fatal(err)
+ }
+ if err := dispatch.Materialize(ctx, admitted, in, work); err != nil {
+ t.Fatal(err)
+ }
+ key := movesFilesPrefix(admitted.Claim.Holder.Location, in.Admission.NewGeneration) + "manifest.json"
+ if data.LatestID(actor.Bucket, key) == "" {
+ t.Fatal("production C8 did not commit")
+ }
+ for _, kind := range []string{hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindReverting, hostclaim.KindRestoring, hostclaim.KindRelocating} {
+ if err := plan9D5Call(dispatch, plan9D5Kind(kind)); err == nil {
+ t.Fatalf("moves-off accepted %s", kind)
+ }
+ }
+ })
+ }
+}
+
+func TestClaimServingMissingSourcesRefusesBeforeSelection(t *testing.T) {
+ for _, field := range []string{"launch", "markers", "files"} {
+ t.Run(field, func(t *testing.T) {
+ f := plan9D5Fixture(t)
+ switch field {
+ case "launch":
+ f.owners.Sources.Launch = nil
+ case "markers":
+ f.owners.Sources.Markers = nil
+ case "files":
+ f.owners.Sources.Files = nil
+ }
+ var dependency *ClaimDependencyError
+ if err := checkClaimServingSources(f.owners.Sources); !errors.As(err, &dependency) {
+ t.Fatalf("missing %s: %v", field, err)
+ }
+ _, err := f.owners.Select(t.Context(), registry.Route{}, plan9D5Observed(hostclaim.Holder{}), recoveryPolicy)
+ if !errors.As(err, &dependency) {
+ t.Fatalf("selection with missing %s: %v", field, err)
+ }
+ if len(f.files) != 0 || f.seq != 0 {
+ t.Fatal("missing dependency had effects")
+ }
+ })
+ }
+}
+
+func TestClaimServingAssemblyFlagMatrix(t *testing.T) {
+ for _, claims := range []string{"", "1"} {
+ for _, moves := range []string{"", "1"} {
+ for _, partial := range []bool{false, true} {
+ name := "claims=" + claims + "/moves=" + moves
+ if partial {
+ name += "/partial"
+ }
+ t.Run(name, func(t *testing.T) {
+ t.Setenv("CARLOS_HOST_CLAIM", claims)
+ t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "")
+ t.Setenv("CARLOS_MOVES_PHASE1", moves)
+ t.Setenv("CARLOS_REGION_HA", "1")
+ store, resources, owners, _ := fix4JournalFixture(t)
+ ctx, err := claimEntryContext(t.Context(), os.Getenv, func(context.Context) (release.Store, error) { return store, nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ f := plan9D5Fixture(t)
+ owners.Serving = f.owners.Sources
+ var calls []string
+ owners.Moves = ClaimMovesOwners{ClaimKindOwners: plan9D5Owner{"moves", &calls}.owners(), Operations: &plan9D2Attempts{}}
+ if partial {
+ owners.Moves.PublicationEvidence = nil
+ }
+ assembly, err := OpenProductionClaimAssembly(ctx, store, resources, owners)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer assembly.Close(ctx)
+ dispatch, bound := assembly.Wake.Inputs.(ClaimOwnerDispatch)
+ if bound != (claims != "") {
+ t.Fatalf("serving binding %v, claims %q", bound, claims)
+ }
+ wantMoves := claims != "" && moves != "" && !partial
+ if bound && (dispatch.Moves != nil) != wantMoves {
+ t.Fatalf("moves binding %v, want %v", dispatch.Moves != nil, wantMoves)
+ }
+ if (assembly.Wake.Operations != nil) != wantMoves {
+ t.Fatal("operation gate changed")
+ }
+ wantMissing := claims != "" && moves != "" && partial
+ if (assembly.wakeUnavailable != nil) != wantMissing {
+ t.Fatalf("partial owner refusal %v, want %v", assembly.wakeUnavailable, wantMissing)
+ }
+ if bound && dispatch.Relocation != nil {
+ t.Fatal("region HA bypassed absent relocation owners")
+ }
+ if len(calls) != 0 || len(f.files) != 0 || f.seq != 0 {
+ t.Fatal("assembly ran a host operation")
+ }
+ })
+ }
+ }
+ }
+}
diff --git a/internal/host/claimfilesseed_enrollment_test.go b/internal/host/claimfilesseed_enrollment_test.go
new file mode 100644
index 000000000..7564ac136
--- /dev/null
+++ b/internal/host/claimfilesseed_enrollment_test.go
@@ -0,0 +1,40 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostwrite"
+)
+
+func TestClaimServingAssemblyRequiresRetirementBeforeRuntime(t *testing.T) {
+ for _, missing := range []string{"legacy", "enrollment", "recovery"} {
+ t.Run(missing, func(t *testing.T) {
+ called := false
+ w := &ClaimWakeAssembly{RequireServingEnrollment: true,
+ BeforeLegacyInstall: func(context.Context, hostclaim.Observed, ClaimLaunchInputs, hostwrite.Work) error {
+ called = true
+ return nil
+ },
+ BeforeInstallLocked: func(context.Context, hostclaim.Observed, ClaimLaunchInputs) error { called = true; return nil },
+ RecoverArtifacts: func(context.Context, hostclaim.Observed, ClaimLaunchInputs) error { called = true; return nil },
+ }
+ switch missing {
+ case "legacy":
+ w.BeforeLegacyInstall = nil
+ case "enrollment":
+ w.BeforeInstallLocked = nil
+ case "recovery":
+ w.RecoverArtifacts = nil
+ }
+ runtime, err := w.Build(t.Context(), hostbox.WorkSource{}, hostbox.Writer{})
+ var dep *ClaimDependencyError
+ if runtime != nil || !errors.As(err, &dep) || called {
+ t.Fatalf("missing %s: runtime=%v err=%v effects=%v", missing, runtime, err, called)
+ }
+ })
+ }
+}
diff --git a/internal/host/claimowners.go b/internal/host/claimowners.go
index 75bf335ae..589bc241c 100644
--- a/internal/host/claimowners.go
+++ b/internal/host/claimowners.go
@@ -328,14 +328,24 @@ type claimServingOwners struct {
published map[string]ClaimPublicationReceipt
}
+func checkClaimServingSources(s ClaimServingSources) error {
+ if s.Launch == nil || s.Markers == nil || s.Files == nil {
+ return &ClaimDependencyError{Contracts: []string{"claimed serving launch, marker and C8 files owners"}}
+ }
+ return nil
+}
+
func (s *claimServingOwners) owners() ClaimKindOwners {
return ClaimKindOwners{Inputs: s, AdmissionEvidence: s.admissionEvidence, PublicationEvidence: s.publicationEvidence}
}
func (s *claimServingOwners) serving(o hostclaim.Observed) error {
- if s == nil || s.Data == nil || s.Bucket == "" || s.Region == "" || s.Box == "" || s.NewID == nil || s.Now == nil || s.Sources.Launch == nil || s.Sources.Markers == nil {
+ if s == nil || s.Data == nil || s.Bucket == "" || s.Region == "" || s.Box == "" || s.NewID == nil || s.Now == nil {
return hostwrite.ErrUnavailable
}
+ if err := checkClaimServingSources(s.Sources); err != nil {
+ return err
+ }
if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.Claim.Op.Kind != hostclaim.KindServing || o.Claim.Op.ID != "" || o.Claim.Op.Phase != "" || o.Claim.Op.Exec != s.Box {
return hostbox.ErrChanged
}
diff --git a/internal/host/claimproduction.go b/internal/host/claimproduction.go
index 6d112723c..60d0710cd 100644
--- a/internal/host/claimproduction.go
+++ b/internal/host/claimproduction.go
@@ -181,20 +181,16 @@ func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store r
tokens := claimAdminTokens{Guard: hostguard.Guard{DataDir: r.DataDir, Claims: hostguard.StoreReader{Store: store}}, Claims: store, Verify: out.Identity.Verify, Local: out.Identity.Local(local), Policy: hostbox.Policy{Held: hostbox.RegistryHoldPolicy(r.Registry)}.Check, Now: time.Now}
out.Serving.Launch = claimServingLaunchSource(backend, tokens)
out.Serving.Markers = marker
- if hostwrite.Enabled(claimEntryEnv(ctx)) && moves.Enabled(claimEntryEnv(ctx)) {
+ moveWakeEnabled := hostwrite.Enabled(claimEntryEnv(ctx)) && moves.Enabled(claimEntryEnv(ctx))
+ if moveWakeEnabled {
out.moveRead = &claimMoveReadAccess{check: claimBoundIdentity(out.Identity.Verify, actor), actor: actor, route: r.Registry.Get, launch: claimMoveLaunchSource(backend)}
}
claimMoveSources.Lock()
external := claimMoveSources.Source
parks := claimMoveSources.Parks
claimMoveSources.Unlock()
- if hostwrite.Enabled(claimEntryEnv(ctx)) && moves.Enabled(claimEntryEnv(ctx)) {
+ if moveWakeEnabled {
openProductionClaimMoveWake(ctx, store, actor, external, &out, claimBoundIdentity(out.Identity.Verify, actor))
- } else if external != nil {
- out.Moves, out.Serving.Files, err = external.Open(ctx, store, out.Data, actor, out.moveRead)
- if err != nil {
- return out, err
- }
}
// The C8 files manifest provider (moves Task 6; platform-1e ruling of
// 2026-10-12): registered under CARLOS_HOST_CLAIM alone, apart from the
@@ -228,7 +224,7 @@ func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store r
if closeFiles != nil {
err = errors.Join(err, closeFiles(ctx))
}
- if external != nil {
+ if external != nil && moveWakeEnabled {
return errors.Join(err, external.Close(ctx))
}
return err
@@ -292,11 +288,8 @@ func claimInputPolicy(route func(string) (*registry.Route, error), store release
}
}
-// bindClaimLegacyOwners fills the legacy owner fields the constructor uses
-// with either flag off. They keep main's typed refusal, before any stop, lease
-// or session. D5's serving owners are built by the constructor from
-// out.Serving, and only under movesC1 (claim Plan 9 D5 gating), so a
-// registered moves source cannot reach them with moves off.
+// bindClaimLegacyOwners fills the claims-off fallback. Claimed serving uses
+// out.Serving independently of moves and never dispatches through these fields.
func bindClaimLegacyOwners(out *ClaimOwnerBindings) {
legacy := claimLegacyRefusal{}
out.Inputs = legacy
diff --git a/internal/host/claimwakeassembly.go b/internal/host/claimwakeassembly.go
index b5eb48b44..5f250030f 100644
--- a/internal/host/claimwakeassembly.go
+++ b/internal/host/claimwakeassembly.go
@@ -95,21 +95,24 @@ type claimSyncer interface {
// ClaimWakeAssembly owns cold restore, lease transfer and failure cleanup.
// Root supplies Stop and Launch; moves supplies only its input/publication API.
type ClaimWakeAssembly struct {
- recoveryIntent func(context.Context, hostclaim.Observed, hostclaim.Admission) (hostclaim.Admission, error)
- recoveryPrior func(context.Context, hostclaim.Observed) (hostclaim.Observed, bool, error)
- recoveryPrelaunched func(context.Context, hostclaim.Observed) error
- retained []*retainedClaimWork
- AfterAdmission func(context.Context, hostclaim.Observed, hostclaim.Observed) error
- BeforeAdmission func(context.Context, hostclaim.Observed) error
- Roles *ClaimRoleAssembly
- LeaseRegion string
- Producers func(context.Context, hostclaim.Observed) (*ClaimProducers, error)
- Inputs ClaimWakeInputs
- Manager *cgroup.Manager
- Retries activator.ServingRetries
- Stop func(context.Context, hostclaim.Observed) error
- RetireArtifacts func(context.Context, hostclaim.Observed, ClaimLaunchInputs, string) error
- RecoverArtifacts func(context.Context, hostclaim.Observed, ClaimLaunchInputs) error
+ recoveryIntent func(context.Context, hostclaim.Observed, hostclaim.Admission) (hostclaim.Admission, error)
+ recoveryPrior func(context.Context, hostclaim.Observed) (hostclaim.Observed, bool, error)
+ recoveryPrelaunched func(context.Context, hostclaim.Observed) error
+ retained []*retainedClaimWork
+ AfterAdmission func(context.Context, hostclaim.Observed, hostclaim.Observed) error
+ BeforeAdmission func(context.Context, hostclaim.Observed) error
+ Roles *ClaimRoleAssembly
+ LeaseRegion string
+ Producers func(context.Context, hostclaim.Observed) (*ClaimProducers, error)
+ Inputs ClaimWakeInputs
+ Manager *cgroup.Manager
+ Retries activator.ServingRetries
+ Stop func(context.Context, hostclaim.Observed) error
+ RetireArtifacts func(context.Context, hostclaim.Observed, ClaimLaunchInputs, string) error
+ RecoverArtifacts func(context.Context, hostclaim.Observed, ClaimLaunchInputs) error
+ RequireServingEnrollment bool
+ BeforeLegacyInstall func(context.Context, hostclaim.Observed, ClaimLaunchInputs, hostwrite.Work) error
+ BeforeInstallLocked func(context.Context, hostclaim.Observed, ClaimLaunchInputs) error
// Prelaunch journals the admitted session's recoverable record before
// restore or materialization; Launch later completes that same record.
PrepareState func(context.Context, hostclaim.Observed, hostwrite.Work, ClaimLaunchInputs) error
@@ -177,6 +180,9 @@ type claimWakeAttempt struct {
}
func (w *ClaimWakeAssembly) Build(ctx context.Context, source hostbox.WorkSource, writer hostbox.Writer) (*activator.ClaimRuntime, error) {
+ if w != nil && w.RequireServingEnrollment && (w.BeforeLegacyInstall == nil || w.BeforeInstallLocked == nil || w.RecoverArtifacts == nil) {
+ return nil, &ClaimDependencyError{Contracts: []string{"serving artifact enrollment and root retirement"}}
+ }
if w == nil || w.LeaseRegion == "" || w.AfterAdmission == nil || w.Inputs == nil || w.Manager == nil || w.Stop == nil || w.RetireArtifacts == nil || w.Retain == nil || w.ObserveLoss == nil || w.Producers == nil {
return nil, hostwrite.ErrUnavailable
}
@@ -406,6 +412,24 @@ func (w *ClaimWakeAssembly) Build(ctx context.Context, source hostbox.WorkSource
_, in := a.snapshot()
selected = claimLeaseWork{Work: selected, Alive: a.ctx, Check: a.leaseErr}
restore := hostbox.ColdRestore{Reader: writer.Data, Bucket: writer.Bucket, Generation: o.Claim.Holder.Generation, Work: selected, Identity: in.Identity, RetireArtifacts: func(ctx context.Context, g string) error { return w.RetireArtifacts(ctx, o, in, g) }}
+ if o.Claim.Op.Kind == hostclaim.KindServing {
+ if w.BeforeLegacyInstall != nil {
+ restore.BeforeLegacyInstall = func(ctx context.Context, path, generation string) error {
+ if path != in.DBPath || generation != o.Claim.Holder.Generation {
+ return hostbox.ErrChanged
+ }
+ return w.BeforeLegacyInstall(ctx, o, in, selected)
+ }
+ }
+ if w.BeforeInstallLocked != nil {
+ restore.BeforeInstallLocked = func(ctx context.Context, path, generation string) error {
+ if path != in.DBPath || generation != o.Claim.Holder.Generation {
+ return hostbox.ErrChanged
+ }
+ return w.BeforeInstallLocked(ctx, o, in)
+ }
+ }
+ }
s := activator.ClaimStart{Live: a.ctx, Work: selected, Identity: in.Identity, Manager: w.Manager, Host: rt.Host, Limit: in.Limit, Version: in.Version, Staged: in.Staged}
s.Restore = func(call context.Context) error {
if err := a.admitsWriters(); err != nil {
diff --git a/internal/host/plan9_i2_movesoff_test.go b/internal/host/plan9_i2_movesoff_test.go
index d381c4210..dc29c85ac 100644
--- a/internal/host/plan9_i2_movesoff_test.go
+++ b/internal/host/plan9_i2_movesoff_test.go
@@ -13,12 +13,10 @@ import (
"github.com/carlosframework/platform/internal/registry"
)
-// I-2: with CARLOS_MOVES_PHASE1 off the constructor uses the provider's
-// legacy owner fields. A registered moves source supplies the serving sources
-// (Files included), but D5's serving owners are reachable only through
-// owners.Serving under movesC1. The legacy fields keep main's typed refusal,
-// so a moves-off serving wake consumes no stop, lease or session.
-func TestPlan9I2MovesOffKeepsLegacyRefusalWithRegisteredSource(t *testing.T) {
+// The claims-off fallback keeps the legacy refusal even when serving
+// sources exist. Claims-on dispatch is covered by the assembly flag matrix.
+func TestClaimOffKeepsLegacyRefusalWithRegisteredSource(t *testing.T) {
+ t.Setenv("CARLOS_HOST_CLAIM", "")
served := 0
d5 := plan9D5Fixture(t)
sources := d5.owners.Sources
diff --git a/internal/hostbox/restore.go b/internal/hostbox/restore.go
index 13a921f5e..b0b024668 100644
--- a/internal/hostbox/restore.go
+++ b/internal/hostbox/restore.go
@@ -33,6 +33,12 @@ type ColdRestore struct {
Work hostwrite.Work
Identity hostwrite.Identity
RetireArtifacts RetireArtifacts
+ // BeforeLegacyInstall parks an unmarked source outside Work.With. The
+ // caller must bind it for serving, including a genuinely empty creator.
+ BeforeLegacyInstall func(context.Context, string, string) error
+ // BeforeInstallLocked validates enrollment after the marker recheck. An
+ // error prevents purge, temporary database creation and marker writes.
+ BeforeInstallLocked func(context.Context, string, string) error
}
// Install purges the old local state and restores plan into dbPath. All local
@@ -64,6 +70,11 @@ func (r ColdRestore) Install(ctx context.Context, dbPath string, plan *genselect
return fmt.Errorf("retire generation %q: %w", capturedGeneration, err)
}
}
+ if capturedGeneration == "" && r.BeforeLegacyInstall != nil {
+ if err := r.BeforeLegacyInstall(ctx, dbPath, r.Generation); err != nil {
+ return err
+ }
+ }
return r.Work.With(ctx, r.Identity, func() error {
currentGeneration, err := readGeneration(dbPath + ".gen")
if err != nil {
@@ -80,6 +91,11 @@ func (r ColdRestore) Install(ctx context.Context, dbPath string, plan *genselect
} else if currentGeneration != capturedGeneration {
return ErrChanged
}
+ if r.BeforeInstallLocked != nil {
+ if err := r.BeforeInstallLocked(ctx, dbPath, r.Generation); err != nil {
+ return err
+ }
+ }
if err := purgeDB(dbPath); err != nil {
return err
}
@@ -169,6 +185,11 @@ func (r ColdRestore) InstallEmpty(ctx context.Context, dbPath string, authority
return err
}
}
+ if captured == "" && r.BeforeLegacyInstall != nil {
+ if err := r.BeforeLegacyInstall(ctx, dbPath, r.Generation); err != nil {
+ return err
+ }
+ }
return r.Work.With(ctx, r.Identity, func() error {
current, err := readGeneration(dbPath + ".gen")
if err != nil {
@@ -181,6 +202,11 @@ func (r ColdRestore) InstallEmpty(ctx context.Context, dbPath string, authority
} else if current != captured {
return ErrChanged
}
+ if r.BeforeInstallLocked != nil {
+ if err := r.BeforeInstallLocked(ctx, dbPath, r.Generation); err != nil {
+ return err
+ }
+ }
if err := purgeDB(dbPath); err != nil {
return err
}
diff --git a/internal/hostbox/restore_enrollment_test.go b/internal/hostbox/restore_enrollment_test.go
new file mode 100644
index 000000000..62e2f5630
--- /dev/null
+++ b/internal/hostbox/restore_enrollment_test.go
@@ -0,0 +1,109 @@
+package hostbox
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/genselect"
+)
+
+func TestColdRestoreEnrollmentPrecedesEveryMutation(t *testing.T) {
+ for _, empty := range []bool{false, true} {
+ for _, refuse := range []string{"", "legacy", "enroll"} {
+ t.Run(map[bool]string{false: "baseline", true: "empty"}[empty]+"/"+refuse, func(t *testing.T) {
+ db := filepath.Join(t.TempDir(), "legacy.db")
+ legacy := []byte("legacy database")
+ if err := os.WriteFile(db, legacy, 0600); err != nil {
+ t.Fatal(err)
+ }
+ work := &restoreWork{}
+ key, body, crc := snapshotFixture(t, "production-restore")
+ rd := &restoreReader{data: map[string][]byte{key + "@v1": body}}
+ plan := &genselect.Plan{Generation: "baseline", MaxTXID: 1, Pin: &genselect.Pin{SnapshotCRC64: crc}, Files: []genselect.File{{Key: key, VersionID: "v1", Level: 9, MinTXID: 1, MaxTXID: 1, Size: int64(len(body))}}}
+ var calls []string
+ failure := errors.New("injected enrollment refusal")
+ r := ColdRestore{Reader: rd, Bucket: "bucket", Generation: "new", Work: work,
+ BeforeLegacyInstall: func(_ context.Context, path, generation string) error {
+ if work.active || path != db || generation != "new" {
+ t.Fatal("legacy callback boundary")
+ }
+ calls = append(calls, "legacy")
+ if refuse == "legacy" {
+ return failure
+ }
+ if string(mustRead(t, db)) != string(legacy) {
+ t.Fatal("legacy source changed before parking")
+ }
+ // Explicit parking seam; root identity/retention belongs to the host tests.
+ return os.Rename(db, db+".parked")
+ },
+ BeforeInstallLocked: func(_ context.Context, path, generation string) error {
+ if !work.active || path != db || generation != "new" {
+ t.Fatal("enrollment callback boundary")
+ }
+ calls = append(calls, "enroll")
+ if _, err := os.Stat(db); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("database appeared before enrollment")
+ }
+ if _, err := os.Stat(db + ".gen"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("marker appeared before enrollment")
+ }
+ if refuse == "enroll" {
+ return failure
+ }
+ return nil
+ }}
+ var err error
+ if empty {
+ err = r.InstallEmpty(t.Context(), db, func(context.Context) error { return nil })
+ } else {
+ err = r.Install(t.Context(), db, plan)
+ }
+ if refuse != "" {
+ if !errors.Is(err, failure) {
+ t.Fatalf("refusal: %v", err)
+ }
+ if _, err := os.Stat(db + ".gen"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("refused enrollment wrote marker")
+ }
+ if refuse == "legacy" {
+ if string(mustRead(t, db)) != string(legacy) {
+ t.Fatal("refusal destroyed legacy database")
+ }
+ } else if string(mustRead(t, db+".parked")) != string(legacy) {
+ t.Fatal("refusal destroyed park")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(calls) != 2 || calls[0] != "legacy" || calls[1] != "enroll" {
+ t.Fatalf("callbacks %v", calls)
+ }
+ if string(mustRead(t, db+".gen")) != "new\n" {
+ t.Fatal("missing successor marker")
+ }
+ if empty {
+ if _, err := os.Stat(db); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("empty restore wrote a database")
+ }
+ return
+ }
+ check, err := sql.Open("sqlite", db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer check.Close()
+ var value string
+ if err := check.QueryRow("SELECT value FROM fixture").Scan(&value); err != nil || value != "production-restore" {
+ t.Fatalf("restored %q: %v", value, err)
+ }
+ })
+ }
+ }
+}
diff --git a/internal/hostguard/servingstop_test.go b/internal/hostguard/servingstop_test.go
new file mode 100644
index 000000000..fb6d5cd47
--- /dev/null
+++ b/internal/hostguard/servingstop_test.go
@@ -0,0 +1,103 @@
+//go:build unix
+
+package hostguard
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+)
+
+func TestCompletedServingStops(t *testing.T) {
+ for _, tc := range []string{"exact", "pending", "wrong-session", "operation", "phase", "obligation", "wrong-box", "mixed-sessions", "view"} {
+ t.Run(tc, func(t *testing.T) {
+ f, target, captured := fenceFixture(t)
+ f.obs.Claim.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: target.Box}
+ target.Op, target.Phase = "", ""
+ target.Region = "uk"
+ f.obs.Claim.Holder.WriterBox = target.Box
+ f.obs.Claim.Holder.Generation = "g7"
+ f.obs.Claim.Holder.Admissions = []hostclaim.AdmissionRecord{{Session: target.Session, Box: target.Box, Region: target.Region, Generation: "g7"}}
+ if _, err := f.g.BeginFence(t.Context(), target); err != nil {
+ t.Fatal(err)
+ }
+ if tc != "pending" {
+ if err := f.g.CompleteFence(t.Context(), target, []byte("receipt"), time.Now(), func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if tc == "operation" || tc == "phase" || tc == "obligation" || tc == "mixed-sessions" {
+ // Negative fixtures deliberately put unusable evidence in the journal.
+ err := f.g.locked(t.Context(), target.Host, func(d *recordDir) error {
+ r, err := d.read(target.Host)
+ if err != nil {
+ return err
+ }
+ switch tc {
+ case "operation":
+ r.FenceJournal[0].Target.Op = "op"
+ case "phase":
+ r.FenceJournal[0].Target.Phase = "phase"
+ case "obligation":
+ r.FenceJournal[0].Target.ObligationID = WriterStopObligation(target.Session)
+ case "mixed-sessions":
+ other := r.FenceJournal[0]
+ other.Target.Session++
+ other.Target.FenceID = "second"
+ r.FenceJournal = append(r.FenceJournal, other)
+ }
+ return d.write(r)
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ if tc == "view" {
+ r, err := readRecord(f.g.DataDir, target.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.g.FenceView = func(context.Context, string, Captured) (FenceView, error) {
+ return FenceView{Schema: 1, Host: target.Host, Captured: captured, Installation: RootInstallation{Host: target.Host, Migration: 1}, Receipts: r.FenceJournal}, nil
+ }
+ }
+ session, box := target.Session, target.Box
+ if tc == "wrong-session" {
+ session++
+ }
+ if tc == "wrong-box" {
+ box = "other"
+ }
+ got, err := f.g.CompletedServingStops(t.Context(), target.Host, captured, session, box)
+ if tc == "exact" || tc == "view" {
+ if err != nil || len(got) != 1 || got[0].Target != target || string(got[0].Bytes) != "receipt" {
+ t.Fatalf("receipts %+v: %v", got, err)
+ }
+ if _, err := f.g.CompletedWriterStops(t.Context(), target.Host, captured, "", box); !errors.Is(err, ErrInvalidInput) {
+ t.Fatalf("operation reader accepted serving: %v", err)
+ }
+ } else if !errors.Is(err, ErrNoFenceReceipt) || len(got) != 0 {
+ t.Fatalf("invalid evidence accepted: %+v %v", got, err)
+ }
+ })
+ }
+}
+
+func TestCompletedServingStopsRejectsZeroIdentity(t *testing.T) {
+ f, target, captured := fenceFixture(t)
+ for _, tc := range []struct {
+ host string
+ c Captured
+ session uint64
+ box string
+ }{
+ {"", captured, 7, "uk"}, {target.Host, Captured{}, 7, "uk"}, {target.Host, captured, 0, "uk"}, {target.Host, captured, 7, ""},
+ } {
+ if _, err := f.g.CompletedServingStops(t.Context(), tc.host, tc.c, tc.session, tc.box); !errors.Is(err, ErrInvalidInput) {
+ t.Fatalf("%+v: %v", tc, err)
+ }
+ }
+}
diff --git a/internal/hostguard/writerstop.go b/internal/hostguard/writerstop.go
index 59ceb0891..5d586188e 100644
--- a/internal/hostguard/writerstop.go
+++ b/internal/hostguard/writerstop.go
@@ -111,6 +111,39 @@ func (g Guard) CompletedWriterStops(ctx context.Context, host string, captured C
return out, err
}
+// CompletedServingStops selects completed stops for one serving installation.
+// Operation receipts cannot stand in for a serving stop.
+func (g Guard) CompletedServingStops(ctx context.Context, host string, captured Captured, session uint64, box string) ([]FenceReceipt, error) {
+ if ctx == nil || host == "" || captured.Incarnation == "" || captured.Stamp == 0 || session == 0 || box == "" {
+ return nil, ErrInvalidInput
+ }
+ var out []FenceReceipt
+ err := g.fenceLocked(ctx, host, captured, func(d *recordDir) error {
+ r, err := d.read(host)
+ if err != nil {
+ return err
+ }
+ for _, receipt := range r.FenceJournal {
+ t := receipt.Target
+ if t.Host != host || t.Incarnation != captured.Incarnation || t.Stamp != captured.Stamp || t.Box != box || t.Op != "" || t.Phase != "" || t.ObligationID != "" {
+ continue
+ }
+ if t.Session != session {
+ return ErrNoFenceReceipt
+ }
+ out = append(out, receipt)
+ }
+ if len(out) == 0 {
+ return ErrNoFenceReceipt
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
func RecoveryWriter(c *hostclaim.Claim, box string) (hostclaim.AdmissionRecord, bool) {
if c == nil || (c.Op.Kind != hostclaim.KindRestoring && c.Op.Kind != hostclaim.KindReverting) || c.Op.Intent["source"] != box {
return hostclaim.AdmissionRecord{}, false
diff --git a/internal/provision/plan14_legacy_identity_test.go b/internal/provision/plan14_legacy_identity_test.go
index 532c38815..474b97ecc 100644
--- a/internal/provision/plan14_legacy_identity_test.go
+++ b/internal/provision/plan14_legacy_identity_test.go
@@ -317,3 +317,42 @@ func TestPlan14Zddr4OverlayPreservesLegacySeal(t *testing.T) {
t.Fatal("unapproved legacy fingerprint accepted")
}
}
+
+func TestPlan14SeedOverlayPreservesLegacySeal(t *testing.T) {
+ root := censusRoot(t)
+ inv := rootReadInventory{Entries: map[string]classifiedRootRead{}, Coverage: map[string]string{}}
+ for i, name := range []string{"plan14-root-reads.tsv", "plan14-root-reads-moves.tsv", "plan14-root-reads-merge.tsv", "plan14-root-reads-fix1.tsv", "plan14-root-reads-fix2.tsv", "plan14-root-reads-merge3.tsv", "plan14-root-reads-fix4.tsv", "plan14-root-reads-fix5.tsv", "plan14-root-reads-fix6.tsv", "plan14-root-reads-zddr3.tsv", "plan14-root-reads-hotfix281.tsv", "plan14-root-reads-zddr4.tsv"} {
+ if err := loadRootReadInventory(root+"/internal/provision/testdata/"+name, &inv, i > 0); err != nil {
+ t.Fatal(err)
+ }
+ }
+ const seal = "e736a5e2652cc546db02c5a385ae7da9fa8a4313d3064f6b431642431974d65d"
+ if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 {
+ t.Fatal(errs)
+ }
+ before := len(inv.Legacy)
+ if err := loadRootReadInventory(root+"/internal/provision/testdata/plan14-root-reads-seed.tsv", &inv, true); err != nil {
+ t.Fatal(err)
+ }
+ if len(inv.Legacy) != before {
+ t.Fatal("seed changed the legacy fingerprints")
+ }
+ if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 {
+ t.Fatal(errs)
+ }
+ for _, key := range []string{
+ "internal/host/claimconstruction.go:OpenProductionClaimAssembly",
+ "internal/host/claimproduction.go:ProductionClaimOwnerProvider.OpenClaimOwners",
+ "internal/host/claimowners.go:checkClaimServingSources",
+ "internal/host/claimowners.go:claimServingOwners.serving",
+ "internal/host/claimwakeassembly.go:ClaimWakeAssembly.Build",
+ "internal/hostbox/restore.go:ColdRestore.Install",
+ "internal/hostbox/restore.go:ColdRestore.InstallEmpty",
+ "internal/hostguard/writerstop.go:Guard.CompletedServingStops",
+ } {
+ e, ok := inv.Entries[key]
+ if !ok || e.Class != "authority" || !strings.HasPrefix(e.Basis, "rev3=PROPOSED-seed;") || !rootReadKeptTask(e.Task) {
+ t.Fatalf("unproposed or unowned seed authority %s: %+v", key, e)
+ }
+ }
+}
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index fe8dbe6b7..bb47df419 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -668,6 +668,12 @@ func TestPlan14RootReadCensus(t *testing.T) {
// activator rows and four new ones (generation socket directories) add
// fourteen frozen declarations.
seal = "e736a5e2652cc546db02c5a385ae7da9fa8a4313d3064f6b431642431974d65d"
+ if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 {
+ t.Fatal(errs)
+ }
+ if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-seed.tsv"), &inv, true); err != nil {
+ t.Fatal(err)
+ }
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/testdata/plan14-root-reads-seed.tsv b/internal/provision/testdata/plan14-root-reads-seed.tsv
new file mode 100644
index 000000000..6fd9e187d
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-seed.tsv
@@ -0,0 +1,15 @@
+# Files seed checkpoint overlay; pending Paul’s census ruling.
+# No root parking or deletion authority is implemented in this checkpoint.
+# 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
+internal/host/claimproduction.go ProductionClaimOwnerProvider.OpenClaimOwners authority C,G rev3=PROPOSED-seed; verified roster, cloud identity, floors and configured provider dependencies through existing helpers; no moves-provider Open or Close with moves disabled f1e9aa15fb6142f839bb76dafa574cad7122d6053cf2099d852ad97a528f1127
+internal/host/claimowners.go checkClaimServingSources authority C rev3=PROPOSED-seed; in-process serving launch, marker and files provider availability before serving selection ed3ad706b8142cd514c04e7e6eeb37a61f881a0e160ffbcb78898d0f1d419351
+internal/host/claimowners.go claimServingOwners.serving authority C,G,J4 rev3=PROPOSED-seed; claim executor and serving shape plus complete serving sources; missing launch, marker or files source refuses file:Exec@450 6d15b7a206526a4df95b4eb2cc5ac5973a7360947ef3eca7a1df8f3e04719719
+internal/host/claimwakeassembly.go ClaimWakeAssembly.Build authority C,G,J1,J4 rev3=PROPOSED-seed; claim/route selection and launch inputs; serving callbacks bind exact database and admitted generation; unbound retirement hooks refuse construction file:Command@9968,file:Prepare@7029,file:Read@1348,file:Read@1442,registry:Backing@2672,registry:Host@12582,registry:Host@3354,registry:Host@3957,registry:Host@4002,registry:Host@4709,registry:Host@5430,registry:Host@6477,registry:Host@7185,registry:Host@7858,registry:Host@9166,registry:Seed@2655,registry:Tunnel@2727,registry:Unit@2692,whole:Route@10106,whole:Route@2899,whole:Route@5830 60104a1c8f22af5a2a9f6aad71d6c0360b193eba93efc3baab6cfddc8145cf12
+internal/hostbox/restore.go ColdRestore.Install authority C,J2,J4 rev3=PROPOSED-seed; selected database versions, local generation marker and restored SQLite data; legacy callback outside Work and enrollment callback before any local write file:CreateTemp@2200,file:Open@3189 7054cf1c887f4b6b4e20b62fd94e17949770c675cbe7e8efe2accf7f20b1c7ba
+internal/hostbox/restore.go ColdRestore.InstallEmpty authority C rev3=PROPOSED-seed; existing empty-bootstrap proof and local marker; legacy callback outside Work and enrollment callback before purge or marker creation f8c0ea4eaf3fa692abe52dd3b0799c9239bcb0d761c69644cc4be78d51d74c04
+internal/hostguard/writerstop.go Guard.CompletedServingStops authority C rev3=PROPOSED-seed; root fence journal through the existing Guard backend; exact host, incarnation, stamp, session and box, with empty operation, phase and obligation; no mixed-session or pending-only evidence 79260151b0fe4f86c59cbe65d727cdceb56028bdfdb208fc9f02cebba1473a98
+@coverage internal/host fde50d7114d67d82f5cebef8e36c26020e2b29f5987b0b6bd818fe7c17b5476c
+@coverage internal/hostbox fa8a9baca13bbb0f7fbc2f47e24e2e5187a728372b247359a3f61d455e7e4cd5
+@coverage internal/hostguard 51408b42f01be94a232e395494e5d154cc57736e32a6519ac6c03e9ff3d091e6