WIP files seed checkpoint 9 (not for merge)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+842
−22
internal/host/claimretire_transport.go+4 −1internal/host/claimservingpark_linux.go+106 −3internal/host/claimservingpark_records.go+11 −0internal/host/claimservingpark_recovery.go+151 −0internal/host/claimservingpark_recovery_linux.go+110 −0internal/host/claimservingpark_recovery_linux_test.go+391 −0internal/host/claimservingpark_rootrunner_linux_test.go+25 −2internal/host/claimservingparkroot_linux.go+22 −9internal/provision/testdata/plan14-root-reads-seed.tsv+22 −7
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 346.763s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.120s
ok github.com/carlosframework/platform/cmd/moveprobe 93.123s
ok github.com/carlosframework/platform/cmd/zddprobe 0.705s
ok github.com/carlosframework/platform/internal/activator 234.385s
ok github.com/carlosframework/platform/internal/agentsock 0.019s
ok github.com/carlosframework/platform/internal/alerting 0.007s
ok github.com/carlosframework/platform/internal/analytics 0.036s
ok github.com/carlosframework/platform/internal/appconfig 2.318s
ok github.com/carlosframework/platform/internal/apperr 0.092s
ok github.com/carlosframework/platform/internal/applog 0.145s
ok github.com/carlosframework/platform/internal/awsinv 0.010s
ok github.com/carlosframework/platform/internal/boxchoice 0.010s
ok github.com/carlosframework/platform/internal/boxid 0.625s
? 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.011s
ok github.com/carlosframework/platform/internal/caps 0.024s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.698s
ok github.com/carlosframework/platform/internal/changelog 0.298s
ok github.com/carlosframework/platform/internal/clamd 0.058s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 364.970s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 5.639s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.018s
ok github.com/carlosframework/platform/internal/dbverify 0.006s
ok github.com/carlosframework/platform/internal/dnsrec 0.006s
ok github.com/carlosframework/platform/internal/docsite 0.011s
ok github.com/carlosframework/platform/internal/domains 2.120s
ok github.com/carlosframework/platform/internal/doorkey 0.012s
ok github.com/carlosframework/platform/internal/economics 0.006s
ok github.com/carlosframework/platform/internal/edge 26.001s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.014s
ok github.com/carlosframework/platform/internal/fleetapi 0.135s
ok github.com/carlosframework/platform/internal/gate 1.665s
ok github.com/carlosframework/platform/internal/genselect 0.030s
ok github.com/carlosframework/platform/internal/geo 0.027s
ok github.com/carlosframework/platform/internal/geoip 0.038s
ok github.com/carlosframework/platform/internal/host 138.304s
ok github.com/carlosframework/platform/internal/hostactors 0.343s
ok github.com/carlosframework/platform/internal/hostbox 2.138s
ok github.com/carlosframework/platform/internal/hostclaim 0.507s
ok github.com/carlosframework/platform/internal/hostevidence 0.015s
ok github.com/carlosframework/platform/internal/hostexec 4.845s
ok github.com/carlosframework/platform/internal/hostfence 0.042s
ok github.com/carlosframework/platform/internal/hostfiles 10.403s
ok github.com/carlosframework/platform/internal/hostfloor 0.015s
ok github.com/carlosframework/platform/internal/hostguard 0.982s
ok github.com/carlosframework/platform/internal/hostops 1.450s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.015s
ok github.com/carlosframework/platform/internal/hostquarantine 0.015s
ok github.com/carlosframework/platform/internal/hostrelocate 0.468s
ok github.com/carlosframework/platform/internal/hostresolve 0.139s
ok github.com/carlosframework/platform/internal/hostwrite 0.015s
ok github.com/carlosframework/platform/internal/infraview 0.015s
ok github.com/carlosframework/platform/internal/instancectl 1.816s
ok github.com/carlosframework/platform/internal/instanceexec 2.710s
ok github.com/carlosframework/platform/internal/instancefs 0.021s
ok github.com/carlosframework/platform/internal/instancekey 0.015s
ok github.com/carlosframework/platform/internal/instanceupdates 0.021s
ok github.com/carlosframework/platform/internal/layout 1.421s
ok github.com/carlosframework/platform/internal/lease 9.480s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.021s
ok github.com/carlosframework/platform/internal/ledger 0.024s
ok github.com/carlosframework/platform/internal/mail 0.149s
ok github.com/carlosframework/platform/internal/maintenance 0.085s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.497s
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 20.945s
ok github.com/carlosframework/platform/internal/moves/archive 0.761s
ok github.com/carlosframework/platform/internal/objstore 0.167s
ok github.com/carlosframework/platform/internal/ops 0.169s
ok github.com/carlosframework/platform/internal/park 0.199s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 11.125s
ok github.com/carlosframework/platform/internal/publicrelease 0.008s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.018s
ok github.com/carlosframework/platform/internal/relay 7.233s
ok github.com/carlosframework/platform/internal/release 18.966s
ok github.com/carlosframework/platform/internal/rollout 3.655s
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.028s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.272s
ok github.com/carlosframework/platform/internal/sim/hostlife 8.951s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.751s
ok github.com/carlosframework/platform/internal/status 0.043s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.325s
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.007s
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.543s
=== 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.023s
=== RUN TestT4StorageProbeMinio
2026/10/14 17:37:49 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE7605F2FA35C3, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.022s
minio contract: PASS
diff --git a/internal/host/claimretire_transport.go b/internal/host/claimretire_transport.go| index 4c174187a..3495b03bd 100644 |
| --- a/internal/host/claimretire_transport.go |
| +++ b/internal/host/claimretire_transport.go |
| @@ -34,7 +34,10 @@ type ClaimRootHTTP struct { |
| Roots map[string]string |
| } |
| -func (h ClaimRootHTTP) RecoverPending(ctx context.Context) error { |
| +func (h ClaimRootHTTP) RecoverPending(ctx context.Context) (recoveryErr error) { |
| + // Serving intents retain their own authority checks even when another |
| + // 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 := cold.Recover(ctx); err != nil { |
diff --git a/internal/host/claimservingpark_linux.go b/internal/host/claimservingpark_linux.go| index ddb47bd02..78f07a7a2 100644 |
| --- a/internal/host/claimservingpark_linux.go |
| +++ b/internal/host/claimservingpark_linux.go |
| @@ -403,6 +403,9 @@ func (f *servingParkFS) Move(r *servingParkRecord, save func() error) error { |
| } |
| func (f *servingParkFS) Delete(r *servingParkRecord, save func() error) error { |
| + if r.EventRemoveIntent { |
| + return f.removeEvent(r, save) |
| + } |
| root, event, err := f.event(r) |
| if err != nil { |
| return err |
| @@ -483,12 +486,30 @@ func (f *servingParkFS) Delete(r *servingParkRecord, save func() error) error { |
| return e |
| } |
| } |
| - // Keep the recorded empty event until tombstone cleanup is implemented. |
| - // It owns no database blocks and cannot be reused by another retirement. |
| - return nil |
| + return f.removeEvent(r, save) |
| } |
| func (f *servingParkFS) CheckParked(r *servingParkRecord) error { |
| + if r.EventRemoved { |
| + if err := f.removedEvent(r); err != nil { |
| + return err |
| + } |
| + source, parent, err := f.source(r.Authorization) |
| + if err != nil { |
| + return err |
| + } |
| + defer source.Close() |
| + if parent != r.Source.Parent { |
| + return hostbox.ErrChanged |
| + } |
| + for _, item := range r.Source.Files { |
| + actual, err := servingParkStat(source, item.Name) |
| + if err != nil || actual.Present { |
| + return hostbox.ErrChanged |
| + } |
| + } |
| + return nil |
| + } |
| root, event, err := f.event(r) |
| if err != nil { |
| return err |
| @@ -533,3 +554,85 @@ func (f *servingParkFS) CheckParked(r *servingParkRecord) error { |
| } |
| return nil |
| } |
| + |
| +// removeEvent removes only a recorded empty event. The durable intent makes |
| +// absence after a lost unlink reply replayable; it never licenses a replacement. |
| +func (f *servingParkFS) removeEvent(r *servingParkRecord, save func() error) error { |
| + if !r.Deleting || !r.Prepared || !r.Parked { |
| + return hostbox.ErrChanged |
| + } |
| + for _, item := range r.Source.Files { |
| + if item.Present && !item.Deleted { |
| + return hostbox.ErrChanged |
| + } |
| + } |
| + root, n, err := f.namespace() |
| + if err != nil { |
| + return err |
| + } |
| + defer root.Close() |
| + if n.Parent != r.Source.Parent { |
| + return hostbox.ErrChanged |
| + } |
| + event, id, err := f.privateDir(root, r.Authorization.ID) |
| + if errors.Is(err, unix.ENOENT) { |
| + if !r.EventRemoveIntent { |
| + return hostbox.ErrChanged |
| + } |
| + } else if err != nil { |
| + return err |
| + } else { |
| + defer event.Close() |
| + if r.EventRemoved || id != r.Event || id.Device != n.Root.Device || id.Mount != n.Root.Mount { |
| + return hostbox.ErrChanged |
| + } |
| + names, e := event.Readdirnames(1) |
| + if e != nil && !errors.Is(e, io.EOF) { |
| + return e |
| + } |
| + if len(names) != 0 { |
| + return hostbox.ErrChanged |
| + } |
| + if !r.EventRemoveIntent { |
| + r.EventRemoveIntent = true |
| + if err = save(); err != nil { |
| + return err |
| + } |
| + } |
| + // Root owns the parent and namespace lock, including during recovery. |
| + if err = f.unlink(int(root.Fd()), r.Authorization.ID, unix.AT_REMOVEDIR); err != nil { |
| + return err |
| + } |
| + } |
| + if err = f.sync(int(root.Fd())); err != nil { |
| + return err |
| + } |
| + if !r.EventRemoved { |
| + r.EventRemoved = true |
| + return save() |
| + } |
| + return nil |
| +} |
| + |
| +func (f *servingParkFS) removedEvent(r *servingParkRecord) error { |
| + if !r.EventRemoveIntent || !r.EventRemoved { |
| + return hostbox.ErrChanged |
| + } |
| + root, n, err := f.namespace() |
| + if err != nil { |
| + return err |
| + } |
| + defer root.Close() |
| + if n.Parent != r.Source.Parent { |
| + return hostbox.ErrChanged |
| + } |
| + d, _, err := f.privateDir(root, r.Authorization.ID) |
| + if err == nil { |
| + d.Close() |
| + return hostbox.ErrChanged |
| + } |
| + if !errors.Is(err, unix.ENOENT) { |
| + return err |
| + } |
| + return nil |
| +} |
diff --git a/internal/host/claimservingpark_records.go b/internal/host/claimservingpark_records.go| index 0f36b8bca..57a0cdc4c 100644 |
| --- a/internal/host/claimservingpark_records.go |
| +++ b/internal/host/claimservingpark_records.go |
| @@ -44,6 +44,7 @@ type servingParkSource struct { |
| type servingParkRecord struct { |
| LegacyEvidence *servingLegacyEvidence `json:",omitempty"` |
| Continuations []servingParkContinuation `json:",omitempty"` |
| + EventRemoveIntent, EventRemoved bool `json:",omitempty"` |
| Version int |
| Authorization servingParkAuthorization |
| Source servingParkSource |
| @@ -211,6 +212,16 @@ func validateServingParkRecord(r servingParkRecord) error { |
| if r.Deleting && !r.Parked { |
| return hostbox.ErrChanged |
| } |
| + if r.EventRemoved && !r.EventRemoveIntent || r.EventRemoveIntent && !r.Deleting { |
| + return hostbox.ErrChanged |
| + } |
| + if r.EventRemoveIntent { |
| + for _, item := range r.Source.Files { |
| + if item.Present && !item.Deleted { |
| + return hostbox.ErrChanged |
| + } |
| + } |
| + } |
| if r.Closed && !r.Deleting { |
| return hostbox.ErrChanged |
| } |
diff --git a/internal/host/claimservingpark_recovery.go b/internal/host/claimservingpark_recovery.go| new file mode 100644 |
| index 000000000..c3e0c0eb5 |
| --- /dev/null |
| +++ b/internal/host/claimservingpark_recovery.go |
| @@ -0,0 +1,151 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "sort" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| +) |
| + |
| +const servingParkRecoveryLimit = 128 |
| + |
| +var errServingParkRetagPending = errors.New("the earlier park is incomplete; the next wake or root service restart completes it before a new park") |
| + |
| +type servingParkRecoveryResult struct { |
| + Host, ID string |
| + Err error |
| +} |
| + |
| +type servingParkRecoveryPage struct { |
| + Results []servingParkRecoveryResult |
| + Next string |
| + More bool |
| +} |
| + |
| +// The cursor bounds record processing and lets a later pass reach records |
| +// beyond a blocked host. Store.Names still enumerates the private directory. |
| +func (l *servingParkLedger) recoveryIDs(ctx context.Context, after string, limit int) ([]string, bool, error) { |
| + if ctx == nil || l == nil || l.state == nil || limit < 1 || limit > servingParkRecoveryLimit { |
| + return nil, false, hostbox.ErrUnsupported |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return nil, false, err |
| + } |
| + names, err := l.state.Names() |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + var ids []string |
| + for _, name := range names { |
| + if strings.HasPrefix(name, "retirement-") && strings.HasSuffix(name, ".json") { |
| + id := strings.TrimSuffix(strings.TrimPrefix(name, "retirement-"), ".json") |
| + if id > after { |
| + ids = append(ids, id) |
| + } |
| + } |
| + } |
| + sort.Strings(ids) |
| + more := len(ids) > limit |
| + if more { |
| + ids = ids[:limit] |
| + } |
| + return ids, more, ctx.Err() |
| +} |
| + |
| +// Completed deletion needs no live claim or agent. Its original durable cause |
| +// and recorded identities remain the authority after a host is removed. |
| +func (l *servingParkLedger) recoverCompleted(ctx context.Context, id string) error { |
| + return l.withNamespace(ctx, func() error { |
| + r, err := l.read(id) |
| + if err != nil { |
| + return err |
| + } |
| + unlock, err := l.state.Lock(ctx, "slot-"+servingParkKey(r.Authorization.Successor.Box, r.Authorization.Successor.Host)+".lock") |
| + if err != nil { |
| + return err |
| + } |
| + defer unlock() |
| + unlockRecord, err := l.state.Lock(ctx, "retirement-"+id+".lock") |
| + if err != nil { |
| + return err |
| + } |
| + defer unlockRecord() |
| + if !r.Complete { |
| + return hostbox.ErrChanged |
| + } |
| + if r.Deleting { |
| + if !r.Closed { |
| + err = l.deleteLocked(ctx, &r, r.Cause) |
| + } else { |
| + // Older records can be closed with their empty event retained. |
| + err = l.fs.Delete(&r, func() error { |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + return l.save(r) |
| + }) |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + } |
| + // Completion may be durable while clearing Pending lost its reply. |
| + // Last and ID are replay references and survive this cleanup. |
| + slot, err := l.slot(r.Authorization) |
| + if err != nil { |
| + return err |
| + } |
| + if slot.Pending == id { |
| + if err = ctx.Err(); err != nil { |
| + return err |
| + } |
| + slot.Pending = "" |
| + return l.write("slot-"+servingParkKey(slot.Box, slot.Host)+".json", slot) |
| + } |
| + return nil |
| + }) |
| +} |
| + |
| +func (p *servingParkRuntime) recoverPending(ctx context.Context) error { |
| + if p == nil { |
| + return nil |
| + } |
| + call, done, err := p.begin(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + defer done() |
| + recovery, ok := p.authority.(interface { |
| + recoverServingParks(context.Context, string, int) (servingParkRecoveryPage, error) |
| + }) |
| + if !ok { |
| + return hostbox.ErrUnsupported |
| + } |
| + page, err := recovery.recoverServingParks(call, "", servingParkRecoveryLimit) |
| + var failures []error |
| + if err != nil { |
| + failures = append(failures, hostbox.ErrChanged) |
| + } |
| + for _, result := range page.Results { |
| + if result.Err != nil { |
| + // Draft retirement.reap_failed and reason.park_pending from the index. |
| + reason := "a local park operation failed; inspect the park listing and root service journal" |
| + if errors.Is(result.Err, errServingParkRetagPending) { |
| + reason = errServingParkRetagPending.Error() |
| + } else if errors.Is(result.Err, hostbox.ErrChanged) || errors.Is(result.Err, ErrClaimStopReceipt) { |
| + reason = "the parked artifact or its parent no longer matches the root-recorded identity" |
| + } |
| + if result.Host == "unknown" { |
| + reason = "the private retirement record could not be validated; retain the data for investigation" |
| + } |
| + failures = append(failures, fmt.Errorf("Host %q: serving database retirement %q retained: %s.", result.Host, result.ID, reason)) |
| + } |
| + } |
| + if page.More { |
| + failures = append(failures, hostbox.ErrChanged) |
| + } |
| + return errors.Join(failures...) |
| +} |
diff --git a/internal/host/claimservingpark_recovery_linux.go b/internal/host/claimservingpark_recovery_linux.go| new file mode 100644 |
| index 000000000..df0e58392 |
| --- /dev/null |
| +++ b/internal/host/claimservingpark_recovery_linux.go |
| @@ -0,0 +1,110 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "reflect" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| +) |
| + |
| +// Startup resumes only an existing immutable intent for its active successor. |
| +// A newer successor needs the ordinary request's stopped-continuation checks. |
| +// Incomplete paired retagging remains the independent claim writer's work. |
| +func (p *servingParkRoot) recoverServingPark(ctx context.Context, saved servingParkRecord) error { |
| + active := saved.activeAuthorization() |
| + return p.withRootInstallation(ctx, active.Successor, ctx.Err, func(o hostclaim.Observed, view hostguard.FenceView, actor hostbox.Actor, dir servingArtifactDirectory, tags uint64) error { |
| + r, err := p.ledger.read(saved.Authorization.ID) |
| + if err != nil || !reflect.DeepEqual(saved, r) || r.Complete || active.Directory != dir || (tags != active.TagsFrom && tags != active.Successor.Stamp) { |
| + return hostbox.ErrChanged |
| + } |
| + if err = validateServingRootHistory(ctx, o, view, actor, r); err != nil { |
| + return err |
| + } |
| + // Any main-role intent is ambiguous at startup, even if a start reply |
| + // was lost. Never resume a rename beneath a potentially live successor. |
| + if err = p.ledger.legacyUnlaunched(ctx, active.Successor, actor.Enrollment); err != nil { |
| + return err |
| + } |
| + if r.Authorization.Legacy { |
| + consumed, e := p.ledger.legacyConsumption(active.Successor) |
| + if e != nil || consumed == nil || !sameServingLegacyConsumption(*consumed, r) { |
| + return hostbox.ErrChanged |
| + } |
| + if err = p.validateLegacyRootEvidence(ctx, o, view, actor, r.Authorization, r.LegacyEvidence, r.Receipts); err != nil { |
| + return err |
| + } |
| + } else { |
| + prior, e := servingPredecessor(o.Claim, active.Successor, r.Authorization.Predecessor.Stamp, r.Authorization.Predecessor.Generation) |
| + if e != nil || prior != r.Authorization.Predecessor { |
| + return hostbox.ErrChanged |
| + } |
| + receipts, e := servingRootStops(ctx, view, prior, actor) |
| + if e != nil || !sameServingRootStops(receipts, r.Receipts) { |
| + return ErrClaimStopReceipt |
| + } |
| + disposition, e := servingGenerationParkDisposition(ctx, p.ledger.journal, prior, receipts, r.Source.Files[0].Present) |
| + if e != nil || disposition != r.Disposition { |
| + return hostbox.ErrChanged |
| + } |
| + } |
| + r, err = p.ledger.parkWithEvidenceLocked(ctx, r.Authorization, r.Receipts, r.LegacyEvidence) |
| + if err != nil { |
| + return err |
| + } |
| + if tags != active.Successor.Stamp { |
| + return errServingParkRetagPending |
| + } |
| + return p.ledger.completeLocked(ctx, r.Authorization.ID, active.Successor) |
| + }) |
| +} |
| + |
| +func sameServingLegacyConsumption(c servingLegacyConsumption, r servingParkRecord) bool { |
| + if c.Authorization != r.Authorization || !sameServingRootStops(c.Receipts, r.Receipts) || !sameServingLegacyEvidence(c.LegacyEvidence, r.LegacyEvidence) || c.Source.Directory != r.Source.Directory || c.Source.Parent != r.Source.Parent { |
| + return false |
| + } |
| + for i, item := range c.Source.Files { |
| + other := r.Source.Files[i] |
| + item.Moved, item.DeleteIntent, item.Deleted = false, false, false |
| + other.Moved, other.DeleteIntent, other.Deleted = false, false, false |
| + if item != other { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +func (p *servingParkRoot) recoverServingParks(ctx context.Context, after string, limit int) (servingParkRecoveryPage, error) { |
| + var out servingParkRecoveryPage |
| + if p == nil || p.ledger == nil { |
| + return out, hostbox.ErrUnsupported |
| + } |
| + ids, more, err := p.ledger.recoveryIDs(ctx, after, limit) |
| + if err != nil { |
| + return out, err |
| + } |
| + out.More = more |
| + for _, id := range ids { |
| + if err = ctx.Err(); err != nil { |
| + out.More = true |
| + return out, err |
| + } |
| + r, readErr := p.ledger.read(id) |
| + result := servingParkRecoveryResult{Host: "unknown", ID: id, Err: readErr} |
| + if readErr == nil { |
| + result.Host = r.Authorization.Successor.Host |
| + switch { |
| + case !r.Complete: |
| + result.Err = p.recoverServingPark(ctx, r) |
| + default: |
| + result.Err = p.ledger.recoverCompleted(ctx, id) |
| + } |
| + } |
| + out.Results = append(out.Results, result) |
| + out.Next = id |
| + } |
| + return out, nil |
| +} |
diff --git a/internal/host/claimservingpark_recovery_linux_test.go b/internal/host/claimservingpark_recovery_linux_test.go| new file mode 100644 |
| index 000000000..14503a6e6 |
| --- /dev/null |
| +++ b/internal/host/claimservingpark_recovery_linux_test.go |
| @@ -0,0 +1,391 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func recoveryPage(t *testing.T, p *servingParkRoot) servingParkRecoveryPage { |
| + t.Helper() |
| + page, err := p.recoverServingParks(t.Context(), "", servingParkRecoveryLimit) |
| + if err != nil || page.More || len(page.Results) != 1 { |
| + t.Fatalf("recovery page: %+v %v", page, err) |
| + } |
| + return page |
| +} |
| + |
| +func TestServingParkStartupResumesRecordedIntent(t *testing.T) { |
| + for _, boundary := range []string{"first-rename", "last-rename", "parked", "retagged", "complete-slot-pending"} { |
| + t.Run(boundary, func(t *testing.T) { |
| + f := newServingRootFixture(t, true) |
| + fs := f.p.ledger.fs.(*servingParkFS) |
| + count := 0 |
| + fs.rename = func(a int, b string, c int, d string, flags uint) error { |
| + err := unix.Renameat2(a, b, c, d, flags) |
| + count++ |
| + if err == nil && (boundary == "first-rename" && count == 1 || boundary == "last-rename" && count == 4) { |
| + return unix.EIO |
| + } |
| + return err |
| + } |
| + a, err := f.park() |
| + if strings.HasSuffix(boundary, "rename") { |
| + if !errors.Is(err, unix.EIO) { |
| + t.Fatalf("lost rename reply: %v", err) |
| + } |
| + a.ID = servingRetirementID(f.prior, f.current, false) |
| + } else if err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := f.p.ledger.read(a.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if boundary == "retagged" || boundary == "complete-slot-pending" { |
| + f.tags(f.current.Stamp) |
| + } |
| + if boundary == "complete-slot-pending" { |
| + before.Complete = true |
| + if err = f.p.ledger.save(before); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + claimBefore := f.f.read() |
| + // New authority instance, same durable stores. Startup has no agent |
| + // connection and must not invoke a transport peer callback. |
| + restarted := *f.p |
| + ledger := *f.p.ledger |
| + restarted.ledger = &ledger |
| + newfs := newServingParkFS(fs.data, fs.state) |
| + newfs.owner, newfs.mount = fs.owner, fs.mount |
| + ledger.fs = newfs |
| + restarted.root.Stop.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { panic("startup authenticated an agent") } |
| + page := recoveryPage(t, &restarted) |
| + wantComplete := boundary == "retagged" || boundary == "complete-slot-pending" |
| + if wantComplete && page.Results[0].Err != nil || !wantComplete && !errors.Is(page.Results[0].Err, errServingParkRetagPending) { |
| + t.Fatalf("unexpected recovery: %+v", page.Results) |
| + } |
| + after, err := ledger.read(a.ID) |
| + if err != nil || !after.Parked || after.Complete != wantComplete || after.Authorization != before.Authorization || !sameServingRootStops(after.Receipts, before.Receipts) { |
| + t.Fatalf("changed intent or incomplete rename: %+v %v", after, err) |
| + } |
| + if !before.ParkedAt.IsZero() && (!after.ParkedAt.Equal(before.ParkedAt) || !after.ReapAfter.Equal(before.ReapAfter)) { |
| + t.Fatal("startup refreshed retention") |
| + } |
| + if !reflect.DeepEqual(f.f.read(), claimBefore) { |
| + t.Fatal("startup changed claim or retry state") |
| + } |
| + for suffix, want := range map[string]string{"": "served bytes", ".gen": "G", "-wal": "WAL", "-shm": "SHM"} { |
| + body, err := os.ReadFile(filepath.Join(f.f.guard.DataDir, "instances", ".serving-parked", a.ID, f.current.Host+".db"+suffix)) |
| + if err != nil || string(body) != want { |
| + t.Fatalf("retained bytes %s: %q %v", suffix, body, err) |
| + } |
| + } |
| + if !wantComplete { |
| + f.tags(f.current.Stamp) |
| + if page = recoveryPage(t, &restarted); page.Results[0].Err != nil { |
| + t.Fatal(page.Results[0].Err) |
| + } |
| + } |
| + slot, err := ledger.slot(after.Authorization) |
| + if err != nil || slot.Pending != "" || slot.Last != a.ID || slot.ID != a.ID { |
| + t.Fatalf("completion replay references: %+v %v", slot, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestServingParkStartupRefusesChangedAuthority(t *testing.T) { |
| + for _, change := range []string{"enrollment", "revision", "claim", "tags", "directory", "receipt", "launch-intent", "census", "cancel"} { |
| + t.Run(change, func(t *testing.T) { |
| + f := newServingRootFixture(t, true) |
| + a, err := f.park() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.tags(f.current.Stamp) |
| + before, _ := f.disk.Read("retirement-" + a.ID + ".json") |
| + ctx := t.Context() |
| + switch change { |
| + case "enrollment": |
| + f.actor.Enrollment = "new-enrollment" |
| + case "revision": |
| + f.actor.Revision++ |
| + case "claim": |
| + _, err = hostclaim.Update(ctx, f.f.claims, f.f.read(), func(c *hostclaim.Claim) error { c.Holder.WriterBox = "other-box"; return nil }) |
| + case "tags": |
| + f.tags(f.current.Stamp + 1) |
| + case "directory": |
| + err = os.Rename(filepath.Dir(f.db), filepath.Dir(f.db)+"-saved") |
| + if err == nil { |
| + err = os.Mkdir(filepath.Dir(f.db), 0700) |
| + } |
| + case "receipt": |
| + f.mutateRoot(func(m map[string]any) { m["State"].(map[string]any)["fence_journal"] = []any{} }) |
| + case "launch-intent": |
| + target := f.receipt.Target |
| + target.Stamp, target.Session = f.current.Stamp, f.current.Session |
| + err = f.f.journal.Prelaunch(ctx, hostbox.LaunchInventory{Target: target, ControllerPID: 77, ProcessStart: "edge-start", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "carlos/parent", Device: 1, Inode: 2}}}) |
| + if err == nil { |
| + err = f.f.journal.IntendUnit(ctx, hostbox.LaunchReservation{ID: "new:main", Role: "main", InvocationID: "new", Inputs: "digest", Target: target, Group: hostbox.LaunchGroup{RelativePath: "system.slice/carlos-app@shop.service"}, Executable: hostbox.LaunchFile{Device: 1, Inode: 3}}) |
| + } |
| + case "census": |
| + f.p.root.RequireNewWork = func(context.Context) error { return hostbox.ErrChanged } |
| + case "cancel": |
| + var cancel context.CancelFunc |
| + ctx, cancel = context.WithCancel(ctx) |
| + cancel() |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + page, err := f.p.recoverServingParks(ctx, "", 128) |
| + if err == nil && (len(page.Results) != 1 || page.Results[0].Err == nil) { |
| + t.Fatalf("accepted changed %s: %+v", change, page) |
| + } |
| + after, _ := f.disk.Read("retirement-" + a.ID + ".json") |
| + if string(after) != string(before) { |
| + t.Fatal("refusal changed private retirement") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestServingParkStartupDeletionAndEmptyEvent(t *testing.T) { |
| + for _, boundary := range []string{"unlink", "rmdir", "closed-old-event", "replacement", "unknown-child", "missing-no-intent"} { |
| + t.Run(boundary, func(t *testing.T) { |
| + f := newServingParkDiskFixture(t) |
| + f.put("", "retain") |
| + a, receipts := f.evidence(10, true, "missing") |
| + _, err := f.park(a, receipts) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.complete(a) |
| + f.now = f.now.Add(7 * 24 * time.Hour) |
| + event := filepath.Dir(f.path(a, "")) |
| + held, err := os.Open(event) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer held.Close() |
| + f.fs.unlink = func(fd int, name string, flags int) error { |
| + if boundary == "closed-old-event" && flags == unix.AT_REMOVEDIR { |
| + return unix.EIO |
| + } |
| + err := unix.Unlinkat(fd, name, flags) |
| + if err == nil && (boundary == "unlink" && flags == 0 || boundary != "unlink" && flags == unix.AT_REMOVEDIR) { |
| + return unix.EIO |
| + } |
| + return err |
| + } |
| + err = f.ledger.withNamespace(t.Context(), func() error { return f.ledger.reapLocked(t.Context(), a.ID) }) |
| + if !errors.Is(err, unix.EIO) { |
| + t.Fatalf("missing fault: %v", err) |
| + } |
| + r, err := f.ledger.read(a.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if boundary == "closed-old-event" { |
| + r.Closed, r.EventRemoveIntent = true, false |
| + if err = f.ledger.save(r); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if boundary == "replacement" || boundary == "unknown-child" { |
| + if err = os.Mkdir(event, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if boundary == "unknown-child" { |
| + err = os.WriteFile(filepath.Join(event, "victim"), []byte("keep"), 0600) |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if boundary == "missing-no-intent" { |
| + r.EventRemoveIntent = false |
| + if err = f.ledger.save(r); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + f.fs.unlink = unix.Unlinkat |
| + // No live claim/root host installation is needed for a deletion |
| + // already authorized by a complete private retirement. |
| + err = f.ledger.recoverCompleted(t.Context(), a.ID) |
| + refuse := boundary == "replacement" || boundary == "unknown-child" || boundary == "missing-no-intent" |
| + if (err != nil) != refuse { |
| + t.Fatalf("recovery: %v, refuse=%v", err, refuse) |
| + } |
| + if refuse { |
| + if boundary == "replacement" || boundary == "unknown-child" { |
| + if _, err = os.Lstat(event); err != nil { |
| + t.Fatal("refusal removed replacement", err) |
| + } |
| + } |
| + if boundary == "unknown-child" { |
| + body, err := os.ReadFile(filepath.Join(event, "victim")) |
| + if err != nil || string(body) != "keep" { |
| + t.Fatal("refusal removed unknown child", err) |
| + } |
| + } |
| + return |
| + } |
| + r, err = f.ledger.read(a.ID) |
| + if err != nil || !r.Closed || !r.EventRemoved { |
| + t.Fatalf("unfinished cleanup: %+v %v", r, err) |
| + } |
| + if _, err = os.Lstat(event); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("empty event survived", err) |
| + } |
| + slot, err := f.ledger.slot(a) |
| + if err != nil || slot.ID != a.ID || slot.Last != a.ID { |
| + t.Fatal("lost replay reference", err) |
| + } |
| + if err = f.ledger.recoverCompleted(t.Context(), a.ID); err != nil { |
| + t.Fatal("cleanup replay", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestServingParkStartupBoundedScanContinuesAfterCorruptRecord(t *testing.T) { |
| + f := newServingRootFixture(t, true) |
| + a, err := f.park() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.tags(f.current.Stamp) |
| + if err = f.disk.Write("retirement-0000.json", []byte(`{}`)); err != nil { |
| + t.Fatal(err) |
| + } |
| + page, err := f.p.recoverServingParks(t.Context(), "", 1) |
| + if err != nil || !page.More || len(page.Results) != 1 || page.Results[0].Host != "unknown" || page.Results[0].Err == nil { |
| + t.Fatalf("first page: %+v %v", page, err) |
| + } |
| + page, err = f.p.recoverServingParks(t.Context(), page.Next, 1) |
| + if err != nil || page.More || len(page.Results) != 1 || page.Results[0].ID != a.ID || page.Results[0].Err != nil { |
| + t.Fatalf("second page: %+v %v", page, err) |
| + } |
| +} |
| + |
| +func TestServingParkStartupLegacyKeepsConsumption(t *testing.T) { |
| + for _, empty := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "legacy", true: "empty"}[empty], func(t *testing.T) { |
| + f, _ := newServingLegacyRootFixture(t, empty) |
| + a, err := f.p.parkLegacyServingDatabase(t.Context(), f.conn, f.current, servingLegacyPredecessor{Empty: empty}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key := servingLegacyKey("legacy", f.current) |
| + consumedBefore, err := f.disk.Read(key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + stopBefore, err := f.disk.Read(servingLegacyKey("legacy-stop", f.current)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := f.p.ledger.read(a.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.tags(f.current.Stamp) |
| + page := recoveryPage(t, f.p) |
| + if page.Results[0].Err != nil { |
| + t.Fatal(page.Results[0].Err) |
| + } |
| + after, err := f.p.ledger.read(a.ID) |
| + if err != nil || !after.Complete || after.Authorization != before.Authorization || !after.ReapAfter.Equal(before.ReapAfter) { |
| + t.Fatalf("legacy recovery: %+v %v", after, err) |
| + } |
| + consumedAfter, _ := f.disk.Read(key) |
| + stopAfter, _ := f.disk.Read(servingLegacyKey("legacy-stop", f.current)) |
| + if string(consumedBefore) != string(consumedAfter) || string(stopBefore) != string(stopAfter) { |
| + t.Fatal("startup changed legacy evidence") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestServingParkStartupRuntimeJoinsBeforeClose(t *testing.T) { |
| + f := newServingRootFixture(t, true) |
| + if _, err := f.park(); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.tags(f.current.Stamp) |
| + entered, release := make(chan struct{}), make(chan struct{}) |
| + f.p.root.RequireNewWork = func(ctx context.Context) error { |
| + close(entered) |
| + <-ctx.Done() |
| + <-release |
| + return ctx.Err() |
| + } |
| + closed := &servingCloseStore{Store: f.disk} |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + p := &servingParkRuntime{authority: f.p, state: closed, ctx: ctx, cancel: cancel, idle: make(chan struct{})} |
| + done := make(chan error, 1) |
| + go func() { done <- p.recoverPending(t.Context()) }() |
| + select { |
| + case <-entered: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("recovery did not enter authority") |
| + } |
| + short, stop := context.WithTimeout(t.Context(), 20*time.Millisecond) |
| + defer stop() |
| + if err := p.Close(short); !errors.Is(err, context.DeadlineExceeded) || closed.closed.Load() { |
| + t.Fatal("closed underneath recovery", err) |
| + } |
| + close(release) |
| + if err := <-done; err == nil { |
| + t.Fatal("cancelled recovery succeeded") |
| + } |
| + if err := p.Close(t.Context()); err != nil || !closed.closed.Load() { |
| + t.Fatal("recovery did not join", err) |
| + } |
| + if err := p.recoverPending(t.Context()); err == nil { |
| + t.Fatal("closed runtime recovered") |
| + } |
| +} |
| + |
| +func TestServingParkStartupRunsAfterOtherRecoveryFailure(t *testing.T) { |
| + f := newServingRootFixture(t, true) |
| + a, err := f.park() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.tags(f.current.Stamp) |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + defer cancel() |
| + p := &servingParkRuntime{authority: f.p, state: f.disk, ctx: ctx, cancel: cancel, idle: make(chan struct{})} |
| + defer p.Close(t.Context()) |
| + h := f.p.root |
| + h.servingParks = p |
| + bad := filepath.Join(t.TempDir(), "not-a-directory") |
| + if err = os.WriteFile(bad, nil, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.Reconcile.Retirement.JournalDir = bad |
| + if err = h.RecoverPending(t.Context()); err == nil { |
| + t.Fatal("other recovery error was swallowed") |
| + } |
| + r, err := f.p.ledger.read(a.ID) |
| + if err != nil || !r.Complete { |
| + t.Fatalf("serving recovery skipped: %+v %v", r, err) |
| + } |
| +} |
diff --git a/internal/host/claimservingpark_rootrunner_linux_test.go b/internal/host/claimservingpark_rootrunner_linux_test.go| index 139330d79..eee0c1a9e 100644 |
| --- a/internal/host/claimservingpark_rootrunner_linux_test.go |
| +++ b/internal/host/claimservingpark_rootrunner_linux_test.go |
| @@ -134,6 +134,11 @@ func servingRunnerPark(t *testing.T, data string, state rootstate.Store) serving |
| if err = fs.CheckParked(&r); err != nil { |
| t.Fatal(err) |
| } |
| + r.Parked, r.ParkedAt = true, time.Now().UTC() |
| + r.ReapAfter = r.ParkedAt.Add(7 * 24 * time.Hour) |
| + if err = save(); err != nil { |
| + t.Fatal(err) |
| + } |
| return r |
| } |
| @@ -164,8 +169,26 @@ func TestRootServingParkUnitPayload(t *testing.T) { |
| fs := newServingParkFS(data, state) |
| r.Deleting, r.Cause = true, "expiry" |
| save := func() error { return (&servingParkLedger{state: state}).write("fixture.json", r) } |
| - if err := fs.Delete(&r, save); err != nil { |
| - t.Fatal(err) |
| + fs.unlink = func(fd int, name string, flags int) error { |
| + err := unix.Unlinkat(fd, name, flags) |
| + if err == nil && flags == unix.AT_REMOVEDIR { |
| + return unix.EIO |
| + } |
| + return err |
| + } |
| + if err := fs.Delete(&r, save); !errors.Is(err, unix.EIO) { |
| + t.Fatalf("lost empty-directory removal reply: %v", err) |
| + } |
| + body, err := state.Read("fixture.json") |
| + if err != nil || servingParkDecode(body, &r) != nil || !r.EventRemoveIntent || r.EventRemoved { |
| + t.Fatal("missing durable event removal intent", err) |
| + } |
| + fs = newServingParkFS(data, state) |
| + if err = fs.Delete(&r, save); err != nil || !r.EventRemoved { |
| + t.Fatal("installed unit did not recover empty event removal", err) |
| + } |
| + if _, err = os.Lstat(filepath.Join(data, "instances", ".serving-parked", r.Authorization.ID)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("recorded empty event survived", err) |
| } |
| for _, item := range r.Source.Files { |
| if item.Present && !item.Deleted { |
diff --git a/internal/host/claimservingparkroot_linux.go b/internal/host/claimservingparkroot_linux.go| index 2c10fcd26..7004e9eea 100644 |
| --- a/internal/host/claimservingparkroot_linux.go |
| +++ b/internal/host/claimservingparkroot_linux.go |
| @@ -35,7 +35,26 @@ func newServingParkAuthority(h ClaimRootHTTP, state rootstate.Store, empty servi |
| } |
| func (p *servingParkRoot) withAuthority(ctx context.Context, conn net.Conn, current servingArtifactIdentity, fn func(hostclaim.Observed, hostguard.FenceView, hostbox.Actor, servingArtifactDirectory, uint64) error) error { |
| - if ctx == nil || conn == nil || p == nil || p.ledger == nil || p.root.Stop.Authenticate == nil || p.root.RequireNewWork == nil || p.root.InventoryActor == nil || p.root.Journals == nil || p.root.Journals.Inventory == nil || p.root.Journals.Inventory.Remote() || p.root.Stop.Coordinator.Guard.Root == nil || fn == nil { |
| + if conn == nil || fn == nil { |
| + return hostbox.ErrUnsupported |
| + } |
| + return p.withRootInstallation(ctx, current, func() error { |
| + // Waiting for root locks must not preserve a stale peer authentication. |
| + peer, err := p.root.Stop.Authenticate(ctx, conn) |
| + if err != nil { |
| + return err |
| + } |
| + if peer.UID == 0 || peer.PID <= 0 || peer.ProcessStart == "" || peer.BootID == "" || peer.ControllerID == "" { |
| + return ErrClaimStopIdentity |
| + } |
| + return nil |
| + }, fn) |
| +} |
| + |
| +// withRootInstallation is also used by root startup to replay private intents. |
| +// It grants no new admission, enrollment, stop receipt or claim tag. |
| +func (p *servingParkRoot) withRootInstallation(ctx context.Context, current servingArtifactIdentity, check func() error, fn func(hostclaim.Observed, hostguard.FenceView, hostbox.Actor, servingArtifactDirectory, uint64) error) error { |
| + if ctx == nil || check == nil || p == nil || p.ledger == nil || p.root.Stop.Authenticate == nil || p.root.RequireNewWork == nil || p.root.InventoryActor == nil || p.root.Journals == nil || p.root.Journals.Inventory == nil || p.root.Journals.Inventory.Remote() || p.root.Stop.Coordinator.Guard.Root == nil || fn == nil { |
| return hostbox.ErrUnsupported |
| } |
| data := p.root.Roots["data"] |
| @@ -44,16 +63,10 @@ func (p *servingParkRoot) withAuthority(ctx context.Context, conn net.Conn, curr |
| } |
| return p.ledger.withNamespace(ctx, func() error { |
| return p.root.Stop.Coordinator.Guard.WithServingRetirement(ctx, current.Host, hostguard.Captured{Incarnation: current.Incarnation, Stamp: current.Stamp}, func(o hostclaim.Observed, view hostguard.FenceView) error { |
| - // Reauthenticate after waiting for locks: an earlier connection check |
| - // cannot establish that the edge main process is still this caller. |
| - peer, err := p.root.Stop.Authenticate(ctx, conn) |
| - if err != nil { |
| + if err := check(); err != nil { |
| return err |
| } |
| - if peer.UID == 0 || peer.PID <= 0 || peer.ProcessStart == "" || peer.BootID == "" || peer.ControllerID == "" { |
| - return ErrClaimStopIdentity |
| - } |
| - if err = p.root.RequireNewWork(ctx); err != nil { |
| + if err := p.root.RequireNewWork(ctx); err != nil { |
| return err |
| } |
| actor, err := p.root.InventoryActor(ctx) |
diff --git a/internal/provision/testdata/plan14-root-reads-seed.tsv b/internal/provision/testdata/plan14-root-reads-seed.tsv| index d48ae235b..169040101 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-seed.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-seed.tsv |
| @@ -1,4 +1,4 @@ |
| -# Files seed checkpoint 8 overlay; pending Paul’s census ruling. |
| +# 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. |
| # 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. |
| @@ -35,8 +35,8 @@ internal/host/claimservingpark_linux.go newServingParkFS authority C,J4 rev3=PRO |
| internal/host/claimservingpark_linux.go sameServingParkFile authority C,J4 rev3=PROPOSED-seed; recorded basename, presence, mount, device, inode, regular type, single link and allocated-byte identity d5d684356e793dcfd55fe1ec9c609f2320e0155e052caca42475348075e8fd68 |
| internal/host/claimservingpark_linux.go servingParkDirID authority C,J4 rev3=PROPOSED-seed; descriptor statx directory type, device, inode and mount identity file:Statx@102 3480dad6d65fedcc17e678df38cf13352ad3c4a8a9d0a09a395973d92b98094e |
| internal/host/claimservingpark_linux.go servingParkFS.Capture authority C,J4 rev3=PROPOSED-seed; canonical nested source directory, four fixed basenames, source mount and bounded generation-marker bytes through the captured descriptor file:Fstat@920,file:Openat@694,file:ReadAll@1138 c3a9348ddd9548f3fcfdcd45abc42b6a758a6399ed34e92481785e26c110763d |
| -internal/host/claimservingpark_linux.go servingParkFS.CheckParked authority C,J4 rev3=PROPOSED-seed; all recorded event identities, unexpected children and source absence before root completion file:Readdirnames@822 144c738d31a4b6495d742310dc3ac171bd0d70bff902395a8cc77119191ccd22 |
| -internal/host/claimservingpark_linux.go servingParkFS.Delete authority C,J4 rev3=PROPOSED-seed; root-recorded event and artifact identities; validates the whole set before journaled per-name unlink, fsync and progress; no recursive deletion file:Readdirnames@341 8837ae9e8947479346f6dddeb8ea8f13290ad56d9d2b96f315e203f5c2e1646a |
| +internal/host/claimservingpark_linux.go servingParkFS.CheckParked authority C,J4 rev3=PROPOSED-seed; all recorded event identities, unexpected children and source absence before root completion file:Readdirnames@1266 ac675e110e1493ab1ec69359696d462228f1e1265923c2c0d471111851a2c645 |
| +internal/host/claimservingpark_linux.go servingParkFS.Delete authority C,J4 rev3=PROPOSED-seed; recorded event and artifact identities; validates complete child set before journaled unlink, then removes only the recorded empty event with durable intent and progress file:Readdirnames@402 9242121cefd1767725b160092a248a8f60d952e5093b6270ea71dfc377b5300c |
| internal/host/claimservingpark_linux.go servingParkFS.Move authority C,J4 rev3=PROPOSED-seed; recorded source/destination inodes and explicit absences; no-replace same-mount rename, post-rename identity and both parent fsyncs before progress 6e43ae58b6a02d167135fabdde7ade06502511c5237b313e6ca6c2a8e9a2c8d0 |
| internal/host/claimservingpark_linux.go servingParkFS.Prepare authority C,J4 rev3=PROPOSED-seed; recorded private namespace and empty event before persisting event identity; existing unrecorded namespace is refused file:Prepare@24,file:Readdirnames@890 2480401a7da95a24a7557be0b8adff67394689abb418e36d0fafecdb8c1d827b |
| internal/host/claimservingpark_linux.go servingParkFS.event authority C,J4 rev3=PROPOSED-seed; recorded namespace parent and private event mount/device/inode identities 8e2ce5bf2f5931e702b847d664bd5cff5fbf90c71ed74b5a2d52ca06d9e0da7c |
| @@ -49,7 +49,7 @@ internal/host/claimservingpark_linux.go servingParkStat authority C,J4 rev3=PROP |
| internal/host/claimservingpark_other.go newServingParkFS authority C,J4 rev3=PROPOSED-seed; non-Linux refusal or construction of refusal-only filesystem; no filesystem authority 69baa4c747e687578e73b8369f2df9987fe6fb5836b78796cc41c776ba12c802 |
| internal/host/claimservingpark_other.go servingParkFS.Capture authority C,J4 rev3=PROPOSED-seed; non-Linux refusal or construction of refusal-only filesystem; no filesystem authority a5560b338356792386a5a1b2e27cb08ca0d5aa024cd03b567a78dfd986ce7871 |
| internal/host/claimservingpark_other.go servingParkFS.CheckParked authority C,J4 rev3=PROPOSED-seed; non-Linux refusal or construction of refusal-only filesystem; no filesystem authority 3a8cb4d8052bbdea8cf7a300944de72c68012e6efa252c293fc1dc06a3a2150e |
| -internal/host/claimservingpark_other.go servingParkFS.Delete authority C,J4 rev3=PROPOSED-seed; non-Linux refusal or construction of refusal-only filesystem; no filesystem authority 8c169e8428b913d2f05b502ce971be49d5febaad95d8ae811aae21c81f3434ce |
| +internal/host/claimservingpark_other.go servingParkFS.Delete authority C,J4 rev3=PROPOSED-seed; recorded event and artifact identities; validates complete child set before journaled unlink, then removes only the recorded empty event with durable intent and progress 8c169e8428b913d2f05b502ce971be49d5febaad95d8ae811aae21c81f3434ce |
| internal/host/claimservingpark_other.go servingParkFS.Move authority C,J4 rev3=PROPOSED-seed; non-Linux refusal or construction of refusal-only filesystem; no filesystem authority 9d215b168da646166104db5624727038f793d2ae0c40eb71f23883f12b6b4b13 |
| internal/host/claimservingpark_other.go servingParkFS.Prepare authority C,J4 rev3=PROPOSED-seed; non-Linux refusal or construction of refusal-only filesystem; no filesystem authority file:Prepare@22 645770372357707f635351a377e943e78f1c160222385e41f42b80d57adedc29 |
| internal/host/claimservingpark_records.go servingParkDecode authority C,J4 rev3=PROPOSED-seed; strict private-record JSON with unknown-field and trailing-value refusal 189c7944ea9f5d269d39ed6a869b6bd739e3033195f06cc1f2c9f23a23180184 |
| @@ -64,10 +64,10 @@ internal/host/claimservingpark_records.go servingParkLedger.slot authority C,J4 |
| internal/host/claimservingpark_records.go servingParkLedger.withNamespace authority C,J4 rev3=PROPOSED-seed; private rootstate namespace lock and cancellation before invoking root authority; composed with root host authority through the typed journal service b7cfae44fdb7de9ef8130f5581710ae3a1a526bb059b4f616650172490f5c8ac |
| internal/host/claimservingpark_records.go servingParkLedger.write authority C,J4 rev3=PROPOSED-seed; serialized private record sent only to the supplied rootstate store 0635d2c27871e67079119a0f2e3034e817b765d0fe3a47e7d9bdc66fb2779571 |
| internal/host/claimservingpark_records.go type servingParkFilesystem authority C,J4 rev3=PROPOSED-seed; private filesystem effect interface over root-recorded retirement identities; no wire or public request fields file:Prepare@180 b93f0e2af16b66aee1ff71294e215eeba9e661fe696c917c982d9ebc2dc682c7 |
| -internal/host/claimservingpark_records.go validateServingParkRecord authority C,J4 rev3=PROPOSED-seed; version, immutable tuples, exact serving receipt bytes, source identities, per-file progress, disposition, completion and retention deadline invariants; bounded immutable continuation history and exact predecessor stops 7164d44093a2bf9efd5d8798d8bf6674f5b399bca163d098b3f2c9b100e9ea87 |
| +internal/host/claimservingpark_records.go validateServingParkRecord authority C,J4 rev3=PROPOSED-seed; version, immutable tuples, exact serving receipt bytes, source identities, per-file progress, disposition, completion and retention deadline invariants; bounded immutable continuation history and exact predecessor stops a4715a0b91f5573ceaef4af9d2f4492924f7ab079cf11a41f2e99b6b0db2a87d |
| 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; independent active-edge peer authentication, census gate, roster, local root inventory and fresh claim recheck under namespace and root host authority; no registry path or caller receipt file:Read@2432 6d0c84a5a9c5403f166db4f200504a3ccd75ebcd7ddac1736e3ac046237a2882 |
| +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 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 |
| @@ -75,7 +75,7 @@ internal/host/claimservingparkroot_linux.go validateServingRootHistory authority |
| 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 2d65820f0366a1ee92490ef9af8888cfda33292f5a5367d35d353e320f031a85 |
| +@coverage internal/host c10497dab1ac3aef334df090b73e4d20fcc67d43a31dd4d457ead6f4f61de83f |
| @coverage internal/hostbox 4b68d7fc25b29963dcf1b7fe92b047fd2229515d83e5cf424c0835d6e690859d |
| @coverage internal/hostguard 94912485e1e5fad4bad56985dd48ede776cfcb5fe18e0f4a59953584ddab06c0 |
| 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 |
| @@ -122,3 +122,18 @@ 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/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 |
| +internal/host/claimservingpark_records.go type servingParkRecord authority C,J4 rev3=PROPOSED-seed; private versioned retirement shape; additive empty-event deletion intent and completion bits checked alongside artifact deletion progress bfa0992f8494ebae3850471f2377e5d1e08a0a6bdd825bca6a80458d40a2abbe |
| +internal/host/claimservingpark_recovery.go const servingParkRecoveryLimit authority C,J4 rev3=PROPOSED-seed; fixed root-side processing limit of 128 retirement records per recovery page 6b949581334f55a7bae1b4579e56ac74810fc3042fdf568fbfa8e6b3c5a2ef11 |
| +internal/host/claimservingpark_recovery.go var errServingParkRetagPending authority C,J4 rev3=PROPOSED-seed; fixed pending-retag refusal; startup cannot write the independent claim writer tags 8fa87ebef4defae4fc1733528a76e0cc260711c2f5a664308e5368d69286d45b |
| +internal/host/claimservingpark_recovery.go type servingParkRecoveryPage authority C,J4 rev3=PROPOSED-seed; private bounded recovery results, cursor and incomplete indicator; never a transport selector c2fb4445ed50b5237b72e8a5fff9b1b2f6c9dd1397473eeb2c78a6b8c2804b27 |
| +internal/host/claimservingpark_recovery.go type servingParkRecoveryResult authority C,J4 rev3=PROPOSED-seed; root-derived host and record ID with local recovery outcome 4dd13c07e97470a6c5aa4fa98d326de8947422018f0d30d5c5af32d753cfcc39 |
| +internal/host/claimservingpark_recovery.go servingParkLedger.recoveryIDs authority C,J4 rev3=PROPOSED-seed; private rootstate names; sorted bounded retirement page and continuation cursor; malformed record IDs retained for reporting 5b450cd0fb6ae6694bca2f9f75fe972c0d65681981a4caddde7e5c7269108782 |
| +internal/host/claimservingpark_recovery.go servingParkLedger.recoverCompleted authority C,J4 rev3=PROPOSED-seed; completed private deletion intent, original cause, slot and recorded filesystem identities under namespace/slot/record locks; preserves Last and ID replay references without live claim authority 2c7f9aed9d06a42f39ff1fd18bfcea919e5e1a17dcb46cd4274b3ecc68fce98b |
| +internal/host/claimservingpark_recovery.go servingParkRuntime.recoverPending authority C,J4 rev3=PROPOSED-seed; service lifetime cancellation and join; bounded startup results rendered only with indexed host/record/fixed-reason text 0aa3ef7edd0ac2c12b4221907ccd5927e7e3e03a4c2c0b77b669bc10cc49c241 |
| +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 |